openclacky 0.5.5 → 0.6.0

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.clackyrules +4 -0
  3. data/CHANGELOG.md +43 -0
  4. data/README.md +1 -1
  5. data/docs/ui2-architecture.md +124 -0
  6. data/lib/clacky/agent.rb +354 -296
  7. data/lib/clacky/agent_config.rb +1 -7
  8. data/lib/clacky/cli.rb +157 -330
  9. data/lib/clacky/client.rb +68 -36
  10. data/lib/clacky/gitignore_parser.rb +26 -12
  11. data/lib/clacky/model_pricing.rb +6 -2
  12. data/lib/clacky/progress_indicator.rb +1 -1
  13. data/lib/clacky/session_manager.rb +6 -2
  14. data/lib/clacky/tools/file_reader.rb +73 -10
  15. data/lib/clacky/tools/glob.rb +65 -9
  16. data/lib/clacky/tools/grep.rb +44 -116
  17. data/lib/clacky/tools/run_project.rb +5 -0
  18. data/lib/clacky/tools/safe_shell.rb +49 -13
  19. data/lib/clacky/tools/shell.rb +1 -49
  20. data/lib/clacky/tools/web_fetch.rb +2 -2
  21. data/lib/clacky/tools/web_search.rb +38 -26
  22. data/lib/clacky/ui2/README.md +214 -0
  23. data/lib/clacky/ui2/components/base_component.rb +163 -0
  24. data/lib/clacky/ui2/components/common_component.rb +89 -0
  25. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  26. data/lib/clacky/ui2/components/input_area.rb +1029 -0
  27. data/lib/clacky/ui2/components/message_component.rb +76 -0
  28. data/lib/clacky/ui2/components/output_area.rb +112 -0
  29. data/lib/clacky/ui2/components/todo_area.rb +137 -0
  30. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  31. data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
  32. data/lib/clacky/ui2/layout_manager.rb +331 -0
  33. data/lib/clacky/ui2/line_editor.rb +201 -0
  34. data/lib/clacky/ui2/screen_buffer.rb +238 -0
  35. data/lib/clacky/ui2/theme_manager.rb +68 -0
  36. data/lib/clacky/ui2/themes/base_theme.rb +99 -0
  37. data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
  38. data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
  39. data/lib/clacky/ui2/ui_controller.rb +720 -0
  40. data/lib/clacky/ui2/view_renderer.rb +160 -0
  41. data/lib/clacky/ui2.rb +37 -0
  42. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  43. data/lib/clacky/version.rb +1 -1
  44. data/lib/clacky.rb +1 -6
  45. metadata +38 -6
  46. data/lib/clacky/ui/banner.rb +0 -155
  47. data/lib/clacky/ui/enhanced_prompt.rb +0 -540
  48. data/lib/clacky/ui/formatter.rb +0 -209
  49. data/lib/clacky/ui/statusbar.rb +0 -96
data/lib/clacky/agent.rb CHANGED
@@ -4,12 +4,13 @@ require "securerandom"
4
4
  require "json"
5
5
  require "tty-prompt"
6
6
  require "set"
7
+ require "base64"
7
8
  require_relative "utils/arguments_parser"
8
9
 
9
10
  module Clacky
10
11
  class Agent
11
12
  attr_reader :session_id, :messages, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
12
- :cache_stats, :cost_source
13
+ :cache_stats, :cost_source, :ui
13
14
 
14
15
  # System prompt for the coding agent
15
16
  SYSTEM_PROMPT = <<~PROMPT.freeze
@@ -50,7 +51,7 @@ module Clacky
50
51
  NEVER stop after just adding todos without executing them!
51
52
  PROMPT
52
53
 
53
- def initialize(client, config = {}, working_dir: nil)
54
+ def initialize(client, config = {}, working_dir: nil, ui: nil)
54
55
  @client = client
55
56
  @config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
56
57
  @tool_registry = ToolRegistry.new
@@ -64,7 +65,8 @@ module Clacky
64
65
  cache_creation_input_tokens: 0,
65
66
  cache_read_input_tokens: 0,
66
67
  total_requests: 0,
67
- cache_hit_requests: 0
68
+ cache_hit_requests: 0,
69
+ raw_api_usage_samples: [] # Store raw API usage for debugging
68
70
  }
69
71
  @start_time = nil
70
72
  @working_dir = working_dir || Dir.pwd
@@ -72,14 +74,18 @@ module Clacky
72
74
  @total_tasks = 0
73
75
  @cost_source = :estimated # Track whether cost is from API or estimated
74
76
  @task_cost_source = :estimated # Track cost source for current task
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
75
81
 
76
82
  # Register built-in tools
77
83
  register_builtin_tools
78
84
  end
79
85
 
80
86
  # Restore from a saved session
81
- def self.from_session(client, config, session_data)
82
- agent = new(client, config)
87
+ def self.from_session(client, config, session_data, ui: nil)
88
+ agent = new(client, config, ui: ui)
83
89
  agent.restore_session(session_data)
84
90
  agent
85
91
  end
@@ -127,27 +133,28 @@ module Clacky
127
133
  @hooks.add(event, &block)
128
134
  end
129
135
 
130
- def run(user_input, &block)
136
+ def run(user_input, images: [])
131
137
  @start_time = Time.now
132
138
  @task_cost_source = :estimated # Reset for new task
139
+ @previous_total_tokens = 0 # Reset token tracking for new task
133
140
 
134
141
  # Add system prompt as the first message if this is the first run
135
142
  if @messages.empty?
136
143
  system_prompt = build_system_prompt
137
144
  system_message = { role: "system", content: system_prompt }
138
145
 
139
- # Enable caching for system prompt if configured and model supports it
140
- if @config.enable_prompt_caching
141
- system_message[:cache_control] = { type: "ephemeral" }
142
- end
146
+ # Note: Don't set cache_control on system prompt
147
+ # System prompt is usually < 1024 tokens (minimum for caching)
148
+ # Cache control will be set on tools and conversation history instead
143
149
 
144
150
  @messages << system_message
145
151
  end
146
152
 
147
- @messages << { role: "user", content: user_input }
153
+ # Format user message with images if provided
154
+ user_content = format_user_content(user_input, images)
155
+ @messages << { role: "user", content: user_content }
148
156
  @total_tasks += 1
149
157
 
150
- emit_event(:on_start, { input: user_input }, &block)
151
158
  @hooks.trigger(:on_start, user_input)
152
159
 
153
160
  begin
@@ -155,30 +162,29 @@ module Clacky
155
162
  break if should_stop?
156
163
 
157
164
  @iterations += 1
158
- emit_event(:on_iteration, { iteration: @iterations }, &block)
159
165
  @hooks.trigger(:on_iteration, @iterations)
160
166
 
161
167
  # Think: LLM reasoning with tool support
162
- response = think(&block)
168
+ response = think
163
169
 
164
170
  # Debug: check for potential infinite loops
165
171
  if @config.verbose
166
- puts "[DEBUG] Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}"
172
+ @ui&.log("Iteration #{@iterations}: finish_reason=#{response[:finish_reason]}, tool_calls=#{response[:tool_calls]&.size || 'nil'}", level: :debug)
167
173
  end
168
174
 
169
175
  # Check if done (no more tool calls needed)
170
176
  if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
171
- emit_event(:answer, { content: response[:content] }, &block)
177
+ @ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
172
178
  break
173
179
  end
174
180
 
175
- # Emit assistant_message event if there's content before tool calls
181
+ # Show assistant message if there's content before tool calls
176
182
  if response[:content] && !response[:content].empty?
177
- emit_event(:assistant_message, { content: response[:content] }, &block)
183
+ @ui&.show_assistant_message(response[:content])
178
184
  end
179
185
 
180
186
  # Act: Execute tool calls
181
- action_result = act(response[:tool_calls], &block)
187
+ action_result = act(response[:tool_calls])
182
188
 
183
189
  # Observe: Add tool results to conversation context
184
190
  observe(response, action_result[:tool_results])
@@ -188,7 +194,6 @@ module Clacky
188
194
  # If user provided feedback, treat it as a user question/instruction
189
195
  if action_result[:feedback] && !action_result[:feedback].empty?
190
196
  # Add user feedback as a new user message
191
- # Use a clear format that signals this is important user input
192
197
  @messages << {
193
198
  role: "user",
194
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."
@@ -197,19 +202,27 @@ module Clacky
197
202
  next
198
203
  else
199
204
  # User just said "no" without feedback - stop and wait
200
- emit_event(:answer, { content: "Tool execution was denied. Please provide further instructions." }, &block)
205
+ @ui&.show_assistant_message("Tool execution was denied. Please provide further instructions.")
201
206
  break
202
207
  end
203
208
  end
204
209
  end
205
210
 
206
211
  result = build_result(:success)
207
- emit_event(:on_complete, result, &block)
212
+ @ui&.show_complete(
213
+ iterations: result[:iterations],
214
+ cost: result[:total_cost_usd],
215
+ duration: result[:duration_seconds],
216
+ cache_stats: result[:cache_stats]
217
+ )
208
218
  @hooks.trigger(:on_complete, result)
209
219
  result
220
+ rescue Clacky::AgentInterrupted
221
+ # Let CLI handle the interrupt message
222
+ raise
210
223
  rescue StandardError => e
211
224
  result = build_result(:error, error: e.message)
212
- emit_event(:on_complete, result, &block)
225
+ @ui&.show_error("Error: #{e.message}")
213
226
  raise
214
227
  end
215
228
  end
@@ -242,7 +255,8 @@ module Clacky
242
255
  total_cost_usd: @total_cost.round(4),
243
256
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
244
257
  last_status: status.to_s,
245
- cache_stats: @cache_stats
258
+ cache_stats: @cache_stats,
259
+ debug_logs: @debug_logs
246
260
  }
247
261
 
248
262
  # Add error message if status is error
@@ -257,8 +271,6 @@ module Clacky
257
271
  config: {
258
272
  model: @config.model,
259
273
  permission_mode: @config.permission_mode.to_s,
260
- max_iterations: @config.max_iterations,
261
- max_cost_usd: @config.max_cost_usd,
262
274
  enable_compression: @config.enable_compression,
263
275
  enable_prompt_caching: @config.enable_prompt_caching,
264
276
  keep_recent_messages: @config.keep_recent_messages,
@@ -274,9 +286,6 @@ module Clacky
274
286
  private
275
287
 
276
288
  def should_auto_execute?(tool_name, tool_params = {})
277
- # Check if tool is disallowed
278
- return false if @config.disallowed_tools.include?(tool_name)
279
-
280
289
  case @config.permission_mode
281
290
  when :auto_approve
282
291
  true
@@ -362,120 +371,104 @@ module Clacky
362
371
  prompt
363
372
  end
364
373
 
365
- def think(&block)
366
- emit_event(:thinking, { iteration: @iterations }, &block)
374
+ def think
375
+ @ui&.show_progress
367
376
 
368
377
  # Compress messages if needed to reduce cost
369
- compress_messages_if_needed if @config.enable_compression
378
+ compress_messages_if_needed
370
379
 
371
380
  # Always send tools definitions to allow multi-step tool calling
372
- tools_to_send = @tool_registry.allowed_definitions(@config.allowed_tools)
381
+ tools_to_send = @tool_registry.all_definitions
373
382
 
374
- # Show progress indicator while waiting for LLM response
375
- progress = ProgressIndicator.new(verbose: @config.verbose)
376
- progress.start
383
+ # Retry logic for network failures
384
+ max_retries = 10
385
+ retry_delay = 5
386
+ retries = 0
377
387
 
378
388
  begin
379
- # Retry logic for network failures
380
- max_retries = 10
381
- retry_delay = 5
382
- retries = 0
383
-
384
- begin
385
- response = @client.send_messages_with_tools(
386
- @messages,
387
- model: @config.model,
388
- tools: tools_to_send,
389
- max_tokens: @config.max_tokens,
390
- verbose: @config.verbose,
391
- enable_caching: @config.enable_prompt_caching
392
- )
393
- rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
394
- retries += 1
395
- if retries <= max_retries
396
- progress.finish
397
- puts "\n⚠️ Network request failed: #{e.class.name} - #{e.message}"
398
- puts "🔄 Retry #{retries}/#{max_retries}, waiting #{retry_delay} seconds..."
399
- sleep retry_delay
400
- progress.start
401
- retry
402
- else
403
- progress.finish
404
- puts "\n❌ Network request failed after #{max_retries} retries, giving up"
405
- raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
406
- end
389
+ response = @client.send_messages_with_tools(
390
+ @messages,
391
+ model: @config.model,
392
+ tools: tools_to_send,
393
+ max_tokens: @config.max_tokens,
394
+ enable_caching: @config.enable_prompt_caching
395
+ )
396
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
397
+ retries += 1
398
+ if retries <= max_retries
399
+ @ui&.show_warning("Network failed: #{e.message}. Retry #{retries}/#{max_retries}...")
400
+ sleep retry_delay
401
+ retry
402
+ else
403
+ @ui&.show_error("Network failed after #{max_retries} retries: #{e.message}")
404
+ raise Error, "Network connection failed after #{max_retries} retries: #{e.message}"
407
405
  end
406
+ end
408
407
 
409
- track_cost(response[:usage])
408
+ # Stop progress thread (but keep progress line visible)
409
+ @ui&.stop_progress_thread
410
410
 
411
- # Handle truncated responses (when max_tokens limit is reached)
412
- if response[:finish_reason] == "length"
413
- # Count recent truncations to prevent infinite loops
414
- recent_truncations = @messages.last(5).count { |m|
415
- m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
416
- }
411
+ track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
417
412
 
418
- if recent_truncations >= 2
419
- # Too many truncations - task is too complex
420
- progress.finish
421
- puts "\n⚠️ Response truncated multiple times. Task is too complex for a single response." if @config.verbose
422
-
423
- # Create a response that tells the user to break down the task
424
- error_response = {
425
- content: "I apologize, but this task is too complex to complete in a single response. " \
426
- "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
427
- "For example, when creating a long document:\n" \
428
- "1. First create the file with a basic structure\n" \
429
- "2. Then use edit() to add content section by section",
430
- finish_reason: "stop",
431
- tool_calls: nil
432
- }
433
-
434
- # Add this as an assistant message so it appears in conversation
435
- @messages << {
436
- role: "assistant",
437
- content: error_response[:content]
438
- }
439
-
440
- return error_response
441
- end
413
+ # Handle truncated responses (when max_tokens limit is reached)
414
+ if response[:finish_reason] == "length"
415
+ # Count recent truncations to prevent infinite loops
416
+ recent_truncations = @messages.last(5).count { |m|
417
+ m[:role] == "user" && m[:content]&.include?("[SYSTEM] Your response was truncated")
418
+ }
442
419
 
443
- # Insert system message to guide LLM to retry with smaller steps
444
- @messages << {
445
- role: "user",
446
- content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
447
- "- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
448
- "- Break down large tasks into multiple smaller steps\n" \
449
- "- Avoid putting more than 2000 characters in a single tool call argument\n" \
450
- "- Use multiple tool calls instead of one large call"
420
+ if recent_truncations >= 2
421
+ # Too many truncations - task is too complex
422
+ @ui&.show_error("Response truncated multiple times. Task is too complex.")
423
+
424
+ # Create a response that tells the user to break down the task
425
+ error_response = {
426
+ content: "I apologize, but this task is too complex to complete in a single response. " \
427
+ "Please break it down into smaller steps, or reduce the amount of content to generate at once.\n\n" \
428
+ "For example, when creating a long document:\n" \
429
+ "1. First create the file with a basic structure\n" \
430
+ "2. Then use edit() to add content section by section",
431
+ finish_reason: "stop",
432
+ tool_calls: nil
451
433
  }
452
434
 
453
- puts "⚠️ Response truncated due to length limit. Retrying with smaller steps..." if @config.verbose
435
+ # Add this as an assistant message so it appears in conversation
436
+ @messages << {
437
+ role: "assistant",
438
+ content: error_response[:content]
439
+ }
454
440
 
455
- # Recursively retry
456
- return think(&block)
441
+ return error_response
457
442
  end
458
443
 
459
- # Add assistant response to messages
460
- msg = { role: "assistant" }
461
- # Always include content field (some APIs require it even with tool_calls)
462
- # Use empty string instead of null for better compatibility
463
- msg[:content] = response[:content] || ""
464
- msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
465
- @messages << msg
444
+ # Insert system message to guide LLM to retry with smaller steps
445
+ @messages << {
446
+ role: "user",
447
+ content: "[SYSTEM] Your response was truncated due to length limit. Please retry with a different approach:\n" \
448
+ "- For long file content: create the file with structure first, then use edit() to add content section by section\n" \
449
+ "- Break down large tasks into multiple smaller steps\n" \
450
+ "- Avoid putting more than 2000 characters in a single tool call argument\n" \
451
+ "- Use multiple tool calls instead of one large call"
452
+ }
466
453
 
467
- if @config.verbose
468
- puts "\n[DEBUG] Assistant response added to messages:"
469
- puts JSON.pretty_generate(msg)
470
- end
454
+ @ui&.show_warning("Response truncated. Retrying with smaller steps...")
471
455
 
472
- response
473
- ensure
474
- progress.finish
456
+ # Recursively retry
457
+ return think
475
458
  end
459
+
460
+ # Add assistant response to messages
461
+ msg = { role: "assistant" }
462
+ # Always include content field (some APIs require it even with tool_calls)
463
+ # Use empty string instead of null for better compatibility
464
+ msg[:content] = response[:content] || ""
465
+ msg[:tool_calls] = format_tool_calls_for_api(response[:tool_calls]) if response[:tool_calls]
466
+ @messages << msg
467
+
468
+ response
476
469
  end
477
470
 
478
- def act(tool_calls, &block)
471
+ def act(tool_calls)
479
472
  return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
480
473
 
481
474
  denied = false
@@ -486,7 +479,7 @@ module Clacky
486
479
  # Hook: before_tool_use
487
480
  hook_result = @hooks.trigger(:before_tool_use, call)
488
481
  if hook_result[:action] == :deny
489
- emit_event(:tool_denied, call, &block)
482
+ @ui&.show_warning("Tool #{call[:name]} denied by hook")
490
483
  results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
491
484
  next
492
485
  end
@@ -494,34 +487,32 @@ module Clacky
494
487
  # Permission check (if not in auto-approve mode)
495
488
  unless should_auto_execute?(call[:name], call[:arguments])
496
489
  if @config.is_plan_only?
497
- emit_event(:tool_planned, call, &block)
490
+ @ui&.show_info("Planned: #{call[:name]}")
498
491
  results << build_planned_result(call)
499
492
  next
500
493
  end
501
494
 
502
- confirmation = confirm_tool_use?(call, &block)
495
+ confirmation = confirm_tool_use?(call)
503
496
  unless confirmation[:approved]
504
- emit_event(:tool_denied, call, &block)
497
+ @ui&.show_warning("Tool #{call[:name]} denied")
505
498
  denied = true
506
499
  user_feedback = confirmation[:feedback]
507
500
  feedback = user_feedback if user_feedback
508
501
  results << build_denied_result(call, user_feedback)
509
502
 
510
- # If user provided feedback, stop processing remaining tools immediately
511
- # Let the agent respond to the feedback in the next iteration
512
- if user_feedback && !user_feedback.empty?
513
- # Fill in denied results for all remaining tool calls to avoid mismatch
514
- remaining_calls = tool_calls[(index + 1)..-1] || []
515
- remaining_calls.each do |remaining_call|
516
- results << build_denied_result(remaining_call, "Auto-denied due to user feedback on previous tool")
517
- end
518
- break
503
+ # Auto-deny all remaining tools
504
+ remaining_calls = tool_calls[(index + 1)..-1] || []
505
+ remaining_calls.each do |remaining_call|
506
+ reason = user_feedback && !user_feedback.empty? ?
507
+ user_feedback :
508
+ "Auto-denied due to user rejection of previous tool"
509
+ results << build_denied_result(remaining_call, reason)
519
510
  end
520
- next
511
+ break
521
512
  end
522
513
  end
523
514
 
524
- emit_event(:tool_call, call, &block)
515
+ @ui&.show_tool_call(call[:name], call[:arguments])
525
516
 
526
517
  # Execute tool
527
518
  begin
@@ -540,11 +531,16 @@ module Clacky
540
531
  # Hook: after_tool_use
541
532
  @hooks.trigger(:after_tool_use, call, result)
542
533
 
543
- emit_event(:observation, { tool: call[:name], result: result }, &block)
534
+ # Update todos display after todo_manager execution
535
+ if call[:name] == "todo_manager"
536
+ @ui&.update_todos(@todos.dup)
537
+ end
538
+
539
+ @ui&.show_tool_result(tool.format_result(result))
544
540
  results << build_success_result(call, result)
545
541
  rescue StandardError => e
546
542
  @hooks.trigger(:on_tool_error, call, e)
547
- emit_event(:tool_error, { call: call, error: e }, &block)
543
+ @ui&.show_tool_error(e)
548
544
  results << build_error_result(call, e.message)
549
545
  end
550
546
  end
@@ -589,51 +585,58 @@ module Clacky
589
585
  end
590
586
  end
591
587
 
592
- def should_stop?
593
- if @iterations >= @config.max_iterations
594
- puts "\n⚠️ Reached maximum iterations (#{@config.max_iterations})" if @config.verbose
595
- return true
596
- end
588
+ # Interrupt the agent's current run
589
+ # Called when user presses Ctrl+C during agent execution
590
+ def interrupt!
591
+ @interrupted = true
592
+ end
597
593
 
598
- if @total_cost >= @config.max_cost_usd
599
- puts "\n⚠️ Reached maximum cost ($#{@config.max_cost_usd})" if @config.verbose
600
- return true
601
- end
594
+ # Check if agent is currently running
595
+ def running?
596
+ @start_time != nil && !should_stop?
597
+ end
602
598
 
603
- # Check timeout only if configured (nil means no timeout)
604
- if @config.timeout_seconds && Time.now - @start_time > @config.timeout_seconds
605
- puts "\n⚠️ Reached timeout (#{@config.timeout_seconds}s)" if @config.verbose
599
+ def should_stop?
600
+ if @interrupted
601
+ @interrupted = false # Reset for next run
606
602
  return true
607
603
  end
608
604
 
605
+
609
606
  false
610
607
  end
611
608
 
612
- def track_cost(usage)
609
+ def track_cost(usage, raw_api_usage: nil)
613
610
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
611
+ iteration_cost = nil
614
612
  if usage[:api_cost]
615
613
  @total_cost += usage[:api_cost]
616
614
  @cost_source = :api
617
615
  @task_cost_source = :api
618
- puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
616
+ iteration_cost = usage[:api_cost]
617
+ @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
619
618
  else
620
619
  # Priority 2: Calculate from tokens using ModelPricing
621
620
  result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
622
621
  cost = result[:cost]
623
622
  pricing_source = result[:source]
624
-
623
+
625
624
  @total_cost += cost
625
+ iteration_cost = cost
626
626
  # Map pricing source to cost source: :price or :default
627
627
  @cost_source = pricing_source
628
628
  @task_cost_source = pricing_source
629
-
629
+
630
630
  if @config.verbose
631
631
  source_label = pricing_source == :price ? "model pricing" : "default pricing"
632
- puts "[DEBUG] Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}"
633
- 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}"
632
+ @ui&.log("Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}", level: :debug)
633
+ @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)
634
634
  end
635
635
  end
636
636
 
637
+ # Display token usage statistics for this iteration
638
+ display_iteration_tokens(usage, iteration_cost)
639
+
637
640
  # Track cache usage statistics
638
641
  @cache_stats[:total_requests] += 1
639
642
 
@@ -645,6 +648,40 @@ module Clacky
645
648
  @cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
646
649
  @cache_stats[:cache_hit_requests] += 1
647
650
  end
651
+
652
+ # Store raw API usage samples (keep last 3 for debugging)
653
+ if raw_api_usage
654
+ @cache_stats[:raw_api_usage_samples] ||= []
655
+ @cache_stats[:raw_api_usage_samples] << raw_api_usage
656
+ @cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
657
+ end
658
+ end
659
+
660
+ # Display token usage for current iteration
661
+ private def display_iteration_tokens(usage, cost)
662
+ prompt_tokens = usage[:prompt_tokens] || 0
663
+ completion_tokens = usage[:completion_tokens] || 0
664
+ total_tokens = usage[:total_tokens] || (prompt_tokens + completion_tokens)
665
+ cache_write = usage[:cache_creation_input_tokens] || 0
666
+ cache_read = usage[:cache_read_input_tokens] || 0
667
+
668
+ # Calculate token delta from previous iteration
669
+ delta_tokens = total_tokens - @previous_total_tokens
670
+ @previous_total_tokens = total_tokens # Update for next iteration
671
+
672
+ # Prepare data for UI to format and display
673
+ token_data = {
674
+ delta_tokens: delta_tokens,
675
+ prompt_tokens: prompt_tokens,
676
+ completion_tokens: completion_tokens,
677
+ total_tokens: total_tokens,
678
+ cache_write: cache_write,
679
+ cache_read: cache_read,
680
+ cost: cost
681
+ }
682
+
683
+ # Let UI handle formatting and display
684
+ @ui&.show_token_usage(token_data)
648
685
  end
649
686
 
650
687
  def compress_messages_if_needed
@@ -658,57 +695,30 @@ module Clacky
658
695
  original_size = @messages.size
659
696
  target_size = @config.keep_recent_messages + 2
660
697
 
661
- # Show compression progress using ProgressIndicator
662
- progress = ProgressIndicator.new(
663
- verbose: @config.verbose,
664
- message: "🗜️ Compressing conversation history (#{original_size} → ~#{target_size} messages)"
665
- )
666
- progress.start
698
+ @ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
667
699
 
668
- begin
669
- # Find the system message (should be first)
670
- system_msg = @messages.find { |m| m[:role] == "system" }
671
-
672
- # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
673
- recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
700
+ # Find the system message (should be first)
701
+ system_msg = @messages.find { |m| m[:role] == "system" }
674
702
 
675
- # Get messages to compress (everything except system and recent)
676
- messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
703
+ # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
704
+ recent_messages = get_recent_messages_with_tool_pairs(@messages, @config.keep_recent_messages)
677
705
 
678
- if messages_to_compress.empty?
679
- progress.finish
680
- return
681
- end
706
+ # Get messages to compress (everything except system and recent)
707
+ messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
682
708
 
683
- # Create summary of compressed messages
684
- summary = summarize_messages(messages_to_compress)
709
+ return if messages_to_compress.empty?
685
710
 
686
- # Rebuild messages array: [system, summary, recent_messages]
687
- # Preserve cache_control on system message if it exists
688
- rebuilt_messages = [system_msg, summary, *recent_messages].compact
711
+ # Create summary of compressed messages
712
+ summary = summarize_messages(messages_to_compress)
689
713
 
690
- # Re-apply cache control to system message if caching is enabled
691
- if @config.enable_prompt_caching && rebuilt_messages.first&.dig(:role) == "system"
692
- rebuilt_messages.first[:cache_control] = { type: "ephemeral" }
693
- end
714
+ # Rebuild messages array: [system, summary, recent_messages]
715
+ rebuilt_messages = [system_msg, summary, *recent_messages].compact
694
716
 
695
- @messages = rebuilt_messages
717
+ @messages = rebuilt_messages
696
718
 
697
- final_size = @messages.size
719
+ final_size = @messages.size
698
720
 
699
- # Finish progress and show completion message
700
- progress.finish
701
- puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
702
-
703
- # Show detailed summary in verbose mode
704
- if @config.verbose
705
- puts "\n[COMPRESSION SUMMARY]"
706
- puts summary[:content]
707
- puts ""
708
- end
709
- ensure
710
- progress.finish
711
- end
721
+ @ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
712
722
  end
713
723
 
714
724
  def get_recent_messages_with_tool_pairs(messages, count)
@@ -837,77 +847,46 @@ module Clacky
837
847
  }
838
848
  end
839
849
 
840
- def emit_event(type, data, &block)
841
- return unless block
842
-
843
- block.call({
844
- type: type,
845
- data: data,
846
- iteration: @iterations,
847
- cost: @total_cost
848
- })
849
- end
850
-
851
- def confirm_tool_use?(call, &block)
852
- emit_event(:tool_confirmation_required, call, &block)
853
-
850
+ def confirm_tool_use?(call)
854
851
  # Show preview first and check for errors
855
852
  preview_error = show_tool_preview(call)
856
853
 
857
- # If preview detected an error (e.g., edit with non-existent string),
858
- # auto-deny and provide detailed feedback
854
+ # If preview detected an error, auto-deny and provide feedback
859
855
  if preview_error && preview_error[:error]
860
- puts "\nTool call auto-denied due to preview error"
861
-
862
- # Build helpful feedback message
863
- feedback = case call[:name]
864
- when "edit"
865
- "The edit operation will fail because the old_string was not found in the file. " \
866
- "Please use file_reader to read '#{preview_error[:path]}' first, " \
867
- "find the correct string to replace, and try again with the exact string (including whitespace)."
868
- else
869
- "Tool preview error: #{preview_error[:error]}"
870
- end
871
-
856
+ @ui&.show_warning("Tool call auto-denied due to preview error")
857
+ feedback = build_preview_error_feedback(call[:name], preview_error)
872
858
  return { approved: false, feedback: feedback }
873
859
  end
874
860
 
875
- # Then show the confirmation prompt with better formatting
876
- prompt_text = format_tool_prompt(call)
877
- puts "\n❓ #{prompt_text}"
878
-
879
- # Use TTY::Prompt for better input handling
880
- tty_prompt = TTY::Prompt.new(interrupt: :exit)
861
+ # Request confirmation via UI
862
+ if @ui
863
+ prompt_text = format_tool_prompt(call)
864
+ result = @ui.request_confirmation(prompt_text, default: true)
881
865
 
882
- begin
883
- response = tty_prompt.ask(" (Enter/y to approve, n to deny, or provide feedback):", required: false) do |q|
884
- q.modify :strip
866
+ case result
867
+ when true
868
+ { approved: true, feedback: nil }
869
+ when false, nil
870
+ { approved: false, feedback: nil }
871
+ else
872
+ # String feedback
873
+ { approved: false, feedback: result.to_s }
885
874
  end
886
- rescue TTY::Reader::InputInterrupt
887
- # Handle Ctrl+C
888
- puts
889
- return { approved: false, feedback: nil }
890
- end
891
-
892
- # Handle nil response (EOF/pipe input)
893
- if response.nil? || response.empty?
894
- return { approved: true, feedback: nil } # Empty means approved
895
- end
896
-
897
- response_lower = response.downcase
898
-
899
- # "y"/"yes" = approved
900
- if response_lower == "y" || response_lower == "yes"
901
- return { approved: true, feedback: nil }
875
+ else
876
+ # Fallback: auto-approve if no UI
877
+ { approved: true, feedback: nil }
902
878
  end
879
+ end
903
880
 
904
- # "n"/"no" = denied without feedback
905
- if response_lower == "n" || response_lower == "no"
906
- return { approved: false, feedback: nil }
881
+ private def build_preview_error_feedback(tool_name, error_info)
882
+ case tool_name
883
+ when "edit"
884
+ "The edit operation will fail because the old_string was not found in the file. " \
885
+ "Please use file_reader to read '#{error_info[:path]}' first, " \
886
+ "find the correct string to replace, and try again with the exact string (including whitespace)."
887
+ else
888
+ "Tool preview error: #{error_info[:error]}"
907
889
  end
908
-
909
- # Any other input = denied with feedback
910
- { approved: false, feedback: response }
911
890
  end
912
891
 
913
892
  def format_tool_prompt(call)
@@ -947,6 +926,8 @@ module Clacky
947
926
  end
948
927
 
949
928
  def show_tool_preview(call)
929
+ return nil unless @ui
930
+
950
931
  begin
951
932
  args = JSON.parse(call[:arguments], symbolize_names: true)
952
933
 
@@ -957,22 +938,22 @@ module Clacky
957
938
  when "edit"
958
939
  preview_error = show_edit_preview(args)
959
940
  when "shell", "safe_shell"
960
- preview_error = show_shell_preview(args)
941
+ show_shell_preview(args)
961
942
  else
962
943
  # For other tools, show formatted arguments
963
944
  tool = @tool_registry.get(call[:name]) rescue nil
964
945
  if tool
965
946
  formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
966
- puts "\nArgs: #{formatted}"
947
+ @ui&.show_tool_args(formatted)
967
948
  else
968
- puts "\nArgs: #{call[:arguments]}"
949
+ @ui&.show_tool_args(call[:arguments])
969
950
  end
970
951
  end
971
952
 
972
- return preview_error
953
+ preview_error
973
954
  rescue JSON::ParserError
974
- puts " Args: #{call[:arguments]}"
975
- return nil
955
+ @ui&.show_tool_args(call[:arguments])
956
+ nil
976
957
  end
977
958
  end
978
959
 
@@ -980,17 +961,16 @@ module Clacky
980
961
  path = args[:path] || args['path']
981
962
  new_content = args[:content] || args['content'] || ""
982
963
 
983
- puts "\n📝 File: #{path || '(unknown)'}"
964
+ is_new_file = !(path && File.exist?(path))
965
+ @ui&.show_file_write_preview(path, is_new_file: is_new_file)
984
966
 
985
- if path && File.exist?(path)
986
- old_content = File.read(path)
987
- puts "Modifying existing file\n"
988
- show_diff(old_content, new_content, max_lines: 50)
967
+ if is_new_file
968
+ @ui&.show_diff("", new_content, max_lines: 50)
989
969
  else
990
- puts "Creating new file\n"
991
- # Show diff from empty content to new content (all additions)
992
- show_diff("", new_content, max_lines: 50)
970
+ old_content = File.read(path)
971
+ @ui&.show_diff(old_content, new_content, max_lines: 50)
993
972
  end
973
+ nil
994
974
  end
995
975
 
996
976
  def show_edit_preview(args)
@@ -998,20 +978,20 @@ module Clacky
998
978
  old_string = args[:old_string] || args['old_string'] || ""
999
979
  new_string = args[:new_string] || args['new_string'] || ""
1000
980
 
1001
- puts "\n📝 File: #{path || '(unknown)'}"
981
+ @ui&.show_file_edit_preview(path)
1002
982
 
1003
983
  if !path || path.empty?
1004
- puts " ⚠️ No file path provided"
984
+ @ui&.show_file_error("No file path provided")
1005
985
  return { error: "No file path provided for edit operation" }
1006
986
  end
1007
987
 
1008
988
  unless File.exist?(path)
1009
- puts " ⚠️ File not found: #{path}"
1010
- return { error: "File not found: #{path}" }
989
+ @ui&.show_file_error("File not found: #{path}")
990
+ return { error: "File not found: #{path}", path: path }
1011
991
  end
1012
992
 
1013
993
  if old_string.empty?
1014
- puts " ⚠️ No old_string provided (nothing to replace)"
994
+ @ui&.show_file_error("No old_string provided (nothing to replace)")
1015
995
  return { error: "No old_string provided (nothing to replace)" }
1016
996
  end
1017
997
 
@@ -1019,9 +999,19 @@ module Clacky
1019
999
 
1020
1000
  # Check if old_string exists in file
1021
1001
  unless file_content.include?(old_string)
1022
- puts " ⚠️ String to replace not found in file"
1023
- puts " Looking for (first 100 chars):"
1024
- puts " #{old_string[0..100].inspect}"
1002
+ # Log debug info for troubleshooting
1003
+ @debug_logs << {
1004
+ timestamp: Time.now.iso8601,
1005
+ event: "edit_preview_failed",
1006
+ path: path,
1007
+ looking_for: old_string[0..500],
1008
+ file_content_preview: file_content[0..1000],
1009
+ file_size: file_content.length
1010
+ }
1011
+
1012
+ @ui&.show_file_error("String to replace not found in file")
1013
+ @ui&.show_file_error("Looking for (first 100 chars):")
1014
+ @ui&.show_file_error(old_string[0..100].inspect)
1025
1015
  return {
1026
1016
  error: "String to replace not found in file",
1027
1017
  path: path,
@@ -1030,34 +1020,31 @@ module Clacky
1030
1020
  end
1031
1021
 
1032
1022
  new_content = file_content.sub(old_string, new_string)
1033
- show_diff(file_content, new_content, max_lines: 50)
1023
+ @ui&.show_diff(file_content, new_content, max_lines: 50)
1034
1024
  nil # No error
1035
1025
  end
1036
1026
 
1037
1027
  def show_shell_preview(args)
1038
1028
  command = args[:command] || ""
1039
- puts "\n💻 Command: #{command}"
1029
+ @ui&.show_shell_preview(command)
1030
+ nil
1040
1031
  end
1041
1032
 
1042
- def show_diff(old_content, new_content, max_lines: 50)
1043
- require 'diffy'
1044
-
1045
- diff = Diffy::Diff.new(old_content, new_content, context: 3)
1046
- all_lines = diff.to_s(:color).lines
1047
- display_lines = all_lines.first(max_lines)
1033
+ def build_success_result(call, result)
1034
+ # Try to get tool instance to use its format_result_for_llm method
1035
+ tool = @tool_registry.get(call[:name]) rescue nil
1048
1036
 
1049
- display_lines.each { |line| puts line.chomp }
1050
- puts "\n... (#{all_lines.size - max_lines} more lines, diff truncated)" if all_lines.size > max_lines
1051
- rescue LoadError
1052
- # Fallback if diffy is not available
1053
- puts " Old size: #{old_content.bytesize} bytes"
1054
- puts " New size: #{new_content.bytesize} bytes"
1055
- end
1037
+ formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
1038
+ # Tool provides a custom LLM-friendly format
1039
+ tool.format_result_for_llm(result)
1040
+ else
1041
+ # Fallback: use the original result
1042
+ result
1043
+ end
1056
1044
 
1057
- def build_success_result(call, result)
1058
1045
  {
1059
1046
  id: call[:id],
1060
- content: JSON.generate(result)
1047
+ content: JSON.generate(formatted_result)
1061
1048
  }
1062
1049
  end
1063
1050
 
@@ -1133,5 +1120,76 @@ module Clacky
1133
1120
  @tool_registry.register(Tools::TodoManager.new)
1134
1121
  @tool_registry.register(Tools::RunProject.new)
1135
1122
  end
1123
+
1124
+ # Format user content with optional images
1125
+ # @param text [String] User's text input
1126
+ # @param images [Array<String>] Array of image file paths
1127
+ # @return [String|Array] String if no images, Array with text and image_url objects if images present
1128
+ def format_user_content(text, images)
1129
+ return text if images.nil? || images.empty?
1130
+
1131
+ content = []
1132
+ content << { type: "text", text: text } unless text.nil? || text.empty?
1133
+
1134
+ images.each do |image_path|
1135
+ image_url = image_path_to_data_url(image_path)
1136
+ content << { type: "image_url", image_url: { url: image_url } }
1137
+ end
1138
+
1139
+ content
1140
+ end
1141
+
1142
+ # Convert image file path to base64 data URL
1143
+ # @param path [String] File path to image
1144
+ # @return [String] base64 data URL (e.g., "data:image/png;base64,...")
1145
+ def image_path_to_data_url(path)
1146
+ unless File.exist?(path)
1147
+ raise ArgumentError, "Image file not found: #{path}"
1148
+ end
1149
+
1150
+ # Read file as binary
1151
+ image_data = File.binread(path)
1152
+
1153
+ # Detect MIME type from file extension or content
1154
+ mime_type = detect_image_mime_type(path, image_data)
1155
+
1156
+ # Encode to base64
1157
+ base64_data = Base64.strict_encode64(image_data)
1158
+
1159
+ "data:#{mime_type};base64,#{base64_data}"
1160
+ end
1161
+
1162
+ # Detect image MIME type
1163
+ # @param path [String] File path
1164
+ # @param data [String] Binary image data
1165
+ # @return [String] MIME type (e.g., "image/png")
1166
+ def detect_image_mime_type(path, data)
1167
+ # Try to detect from file extension first
1168
+ ext = File.extname(path).downcase
1169
+ case ext
1170
+ when ".png"
1171
+ "image/png"
1172
+ when ".jpg", ".jpeg"
1173
+ "image/jpeg"
1174
+ when ".gif"
1175
+ "image/gif"
1176
+ when ".webp"
1177
+ "image/webp"
1178
+ else
1179
+ # Try to detect from file signature (magic bytes)
1180
+ if data.start_with?("\x89PNG".b)
1181
+ "image/png"
1182
+ elsif data.start_with?("\xFF\xD8\xFF".b)
1183
+ "image/jpeg"
1184
+ elsif data.start_with?("GIF87a".b) || data.start_with?("GIF89a".b)
1185
+ "image/gif"
1186
+ elsif data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
1187
+ "image/webp"
1188
+ else
1189
+ # Default to png if unknown
1190
+ "image/png"
1191
+ end
1192
+ end
1193
+ end
1136
1194
  end
1137
1195
  end