openclacky 0.5.6 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +245 -340
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +156 -397
  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 +65 -9
  12. data/lib/clacky/tools/grep.rb +4 -120
  13. data/lib/clacky/tools/run_project.rb +5 -0
  14. data/lib/clacky/tools/safe_shell.rb +49 -13
  15. data/lib/clacky/tools/shell.rb +1 -49
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +38 -26
  18. data/lib/clacky/ui2/README.md +214 -0
  19. data/lib/clacky/ui2/components/base_component.rb +163 -0
  20. data/lib/clacky/ui2/components/common_component.rb +89 -0
  21. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  22. data/lib/clacky/ui2/components/input_area.rb +1029 -0
  23. data/lib/clacky/ui2/components/message_component.rb +76 -0
  24. data/lib/clacky/ui2/components/output_area.rb +112 -0
  25. data/lib/clacky/ui2/components/todo_area.rb +137 -0
  26. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  27. data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
  28. data/lib/clacky/ui2/layout_manager.rb +331 -0
  29. data/lib/clacky/ui2/line_editor.rb +201 -0
  30. data/lib/clacky/ui2/screen_buffer.rb +238 -0
  31. data/lib/clacky/ui2/theme_manager.rb +68 -0
  32. data/lib/clacky/ui2/themes/base_theme.rb +99 -0
  33. data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
  34. data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
  35. data/lib/clacky/ui2/ui_controller.rb +720 -0
  36. data/lib/clacky/ui2/view_renderer.rb +160 -0
  37. data/lib/clacky/ui2.rb +37 -0
  38. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky.rb +1 -6
  41. metadata +38 -6
  42. data/lib/clacky/ui/banner.rb +0 -155
  43. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  44. data/lib/clacky/ui/formatter.rb +0 -209
  45. 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
@@ -129,7 +133,7 @@ module Clacky
129
133
  @hooks.add(event, &block)
130
134
  end
131
135
 
132
- def run(user_input, images: [], &block)
136
+ def run(user_input, images: [])
133
137
  @start_time = Time.now
134
138
  @task_cost_source = :estimated # Reset for new task
135
139
  @previous_total_tokens = 0 # Reset token tracking for new task
@@ -139,10 +143,9 @@ module Clacky
139
143
  system_prompt = build_system_prompt
140
144
  system_message = { role: "system", content: system_prompt }
141
145
 
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
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
146
149
 
147
150
  @messages << system_message
148
151
  end
@@ -152,7 +155,6 @@ module Clacky
152
155
  @messages << { role: "user", content: user_content }
153
156
  @total_tasks += 1
154
157
 
155
- emit_event(:on_start, { input: user_input }, &block)
156
158
  @hooks.trigger(:on_start, user_input)
157
159
 
158
160
  begin
@@ -160,30 +162,29 @@ module Clacky
160
162
  break if should_stop?
161
163
 
162
164
  @iterations += 1
163
- emit_event(:on_iteration, { iteration: @iterations }, &block)
164
165
  @hooks.trigger(:on_iteration, @iterations)
165
166
 
166
167
  # Think: LLM reasoning with tool support
167
- response = think(&block)
168
+ response = think
168
169
 
169
170
  # Debug: check for potential infinite loops
170
171
  if @config.verbose
171
- 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)
172
173
  end
173
174
 
174
175
  # Check if done (no more tool calls needed)
175
176
  if response[:finish_reason] == "stop" || response[:tool_calls].nil? || response[:tool_calls].empty?
176
- emit_event(:answer, { content: response[:content] }, &block)
177
+ @ui&.show_assistant_message(response[:content]) if response[:content] && !response[:content].empty?
177
178
  break
178
179
  end
179
180
 
180
- # Emit assistant_message event if there's content before tool calls
181
+ # Show assistant message if there's content before tool calls
181
182
  if response[:content] && !response[:content].empty?
182
- emit_event(:assistant_message, { content: response[:content] }, &block)
183
+ @ui&.show_assistant_message(response[:content])
183
184
  end
184
185
 
185
186
  # Act: Execute tool calls
186
- action_result = act(response[:tool_calls], &block)
187
+ action_result = act(response[:tool_calls])
187
188
 
188
189
  # Observe: Add tool results to conversation context
189
190
  observe(response, action_result[:tool_results])
@@ -193,7 +194,6 @@ module Clacky
193
194
  # If user provided feedback, treat it as a user question/instruction
194
195
  if action_result[:feedback] && !action_result[:feedback].empty?
195
196
  # Add user feedback as a new user message
196
- # Use a clear format that signals this is important user input
197
197
  @messages << {
198
198
  role: "user",
199
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."
@@ -202,19 +202,27 @@ module Clacky
202
202
  next
203
203
  else
204
204
  # User just said "no" without feedback - stop and wait
205
- 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.")
206
206
  break
207
207
  end
208
208
  end
209
209
  end
210
210
 
211
211
  result = build_result(:success)
212
- 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
+ )
213
218
  @hooks.trigger(:on_complete, result)
214
219
  result
220
+ rescue Clacky::AgentInterrupted
221
+ # Let CLI handle the interrupt message
222
+ raise
215
223
  rescue StandardError => e
216
224
  result = build_result(:error, error: e.message)
217
- emit_event(:on_complete, result, &block)
225
+ @ui&.show_error("Error: #{e.message}")
218
226
  raise
219
227
  end
220
228
  end
@@ -247,7 +255,8 @@ module Clacky
247
255
  total_cost_usd: @total_cost.round(4),
248
256
  duration_seconds: @start_time ? (Time.now - @start_time).round(2) : 0,
249
257
  last_status: status.to_s,
250
- cache_stats: @cache_stats
258
+ cache_stats: @cache_stats,
259
+ debug_logs: @debug_logs
251
260
  }
252
261
 
253
262
  # Add error message if status is error
@@ -262,8 +271,6 @@ module Clacky
262
271
  config: {
263
272
  model: @config.model,
264
273
  permission_mode: @config.permission_mode.to_s,
265
- max_iterations: @config.max_iterations,
266
- max_cost_usd: @config.max_cost_usd,
267
274
  enable_compression: @config.enable_compression,
268
275
  enable_prompt_caching: @config.enable_prompt_caching,
269
276
  keep_recent_messages: @config.keep_recent_messages,
@@ -279,9 +286,6 @@ module Clacky
279
286
  private
280
287
 
281
288
  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
289
  case @config.permission_mode
286
290
  when :auto_approve
287
291
  true
@@ -367,120 +371,104 @@ module Clacky
367
371
  prompt
368
372
  end
369
373
 
370
- def think(&block)
371
- emit_event(:thinking, { iteration: @iterations }, &block)
374
+ def think
375
+ @ui&.show_progress
372
376
 
373
377
  # Compress messages if needed to reduce cost
374
- compress_messages_if_needed if @config.enable_compression
378
+ compress_messages_if_needed
375
379
 
376
380
  # Always send tools definitions to allow multi-step tool calling
377
- tools_to_send = @tool_registry.allowed_definitions(@config.allowed_tools)
381
+ tools_to_send = @tool_registry.all_definitions
378
382
 
379
- # Show progress indicator while waiting for LLM response
380
- progress = ProgressIndicator.new(verbose: @config.verbose)
381
- progress.start
383
+ # Retry logic for network failures
384
+ max_retries = 10
385
+ retry_delay = 5
386
+ retries = 0
382
387
 
383
388
  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
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}"
412
405
  end
406
+ end
413
407
 
414
- track_cost(response[:usage])
408
+ # Stop progress thread (but keep progress line visible)
409
+ @ui&.stop_progress_thread
415
410
 
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
- }
411
+ track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
422
412
 
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
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
+ }
447
419
 
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"
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
456
433
  }
457
434
 
458
- 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
+ }
459
440
 
460
- # Recursively retry
461
- return think(&block)
441
+ return error_response
462
442
  end
463
443
 
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
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
+ }
471
453
 
472
- if @config.verbose
473
- puts "\n[DEBUG] Assistant response added to messages:"
474
- puts JSON.pretty_generate(msg)
475
- end
454
+ @ui&.show_warning("Response truncated. Retrying with smaller steps...")
476
455
 
477
- response
478
- ensure
479
- progress.finish
456
+ # Recursively retry
457
+ return think
480
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
481
469
  end
482
470
 
483
- def act(tool_calls, &block)
471
+ def act(tool_calls)
484
472
  return { denied: false, feedback: nil, tool_results: [] } unless tool_calls
485
473
 
486
474
  denied = false
@@ -491,7 +479,7 @@ module Clacky
491
479
  # Hook: before_tool_use
492
480
  hook_result = @hooks.trigger(:before_tool_use, call)
493
481
  if hook_result[:action] == :deny
494
- emit_event(:tool_denied, call, &block)
482
+ @ui&.show_warning("Tool #{call[:name]} denied by hook")
495
483
  results << build_error_result(call, hook_result[:reason] || "Tool use denied by hook")
496
484
  next
497
485
  end
@@ -499,34 +487,32 @@ module Clacky
499
487
  # Permission check (if not in auto-approve mode)
500
488
  unless should_auto_execute?(call[:name], call[:arguments])
501
489
  if @config.is_plan_only?
502
- emit_event(:tool_planned, call, &block)
490
+ @ui&.show_info("Planned: #{call[:name]}")
503
491
  results << build_planned_result(call)
504
492
  next
505
493
  end
506
494
 
507
- confirmation = confirm_tool_use?(call, &block)
495
+ confirmation = confirm_tool_use?(call)
508
496
  unless confirmation[:approved]
509
- emit_event(:tool_denied, call, &block)
497
+ @ui&.show_warning("Tool #{call[:name]} denied")
510
498
  denied = true
511
499
  user_feedback = confirmation[:feedback]
512
500
  feedback = user_feedback if user_feedback
513
501
  results << build_denied_result(call, user_feedback)
514
502
 
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
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)
524
510
  end
525
- next
511
+ break
526
512
  end
527
513
  end
528
514
 
529
- emit_event(:tool_call, call, &block)
515
+ @ui&.show_tool_call(call[:name], call[:arguments])
530
516
 
531
517
  # Execute tool
532
518
  begin
@@ -545,11 +531,16 @@ module Clacky
545
531
  # Hook: after_tool_use
546
532
  @hooks.trigger(:after_tool_use, call, result)
547
533
 
548
- 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))
549
540
  results << build_success_result(call, result)
550
541
  rescue StandardError => e
551
542
  @hooks.trigger(:on_tool_error, call, e)
552
- emit_event(:tool_error, { call: call, error: e }, &block)
543
+ @ui&.show_tool_error(e)
553
544
  results << build_error_result(call, e.message)
554
545
  end
555
546
  end
@@ -594,27 +585,28 @@ module Clacky
594
585
  end
595
586
  end
596
587
 
597
- def should_stop?
598
- if @iterations >= @config.max_iterations
599
- puts "\n⚠️ Reached maximum iterations (#{@config.max_iterations})" if @config.verbose
600
- return true
601
- 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
602
593
 
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
594
+ # Check if agent is currently running
595
+ def running?
596
+ @start_time != nil && !should_stop?
597
+ end
607
598
 
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
599
+ def should_stop?
600
+ if @interrupted
601
+ @interrupted = false # Reset for next run
611
602
  return true
612
603
  end
613
604
 
605
+
614
606
  false
615
607
  end
616
608
 
617
- def track_cost(usage)
609
+ def track_cost(usage, raw_api_usage: nil)
618
610
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
619
611
  iteration_cost = nil
620
612
  if usage[:api_cost]
@@ -622,7 +614,7 @@ module Clacky
622
614
  @cost_source = :api
623
615
  @task_cost_source = :api
624
616
  iteration_cost = usage[:api_cost]
625
- puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
617
+ @ui&.log("Using API-provided cost: $#{usage[:api_cost]}", level: :debug) if @config.verbose
626
618
  else
627
619
  # Priority 2: Calculate from tokens using ModelPricing
628
620
  result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
@@ -637,8 +629,8 @@ module Clacky
637
629
 
638
630
  if @config.verbose
639
631
  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}"
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)
642
634
  end
643
635
  end
644
636
 
@@ -656,6 +648,13 @@ module Clacky
656
648
  @cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
657
649
  @cache_stats[:cache_hit_requests] += 1
658
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
659
658
  end
660
659
 
661
660
  # Display token usage for current iteration
@@ -669,53 +668,20 @@ module Clacky
669
668
  # Calculate token delta from previous iteration
670
669
  delta_tokens = total_tokens - @previous_total_tokens
671
670
  @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
671
 
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
-
712
- # Cost for this iteration
713
- if cost
714
- token_info << "Cost: $#{cost.round(6)}"
715
- end
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
+ }
716
682
 
717
- # Display with color
718
- puts pastel.dim(" [Tokens] #{token_info.join(' | ')}")
683
+ # Let UI handle formatting and display
684
+ @ui&.show_token_usage(token_data)
719
685
  end
720
686
 
721
687
  def compress_messages_if_needed
@@ -729,57 +695,30 @@ module Clacky
729
695
  original_size = @messages.size
730
696
  target_size = @config.keep_recent_messages + 2
731
697
 
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
738
-
739
- begin
740
- # Find the system message (should be first)
741
- system_msg = @messages.find { |m| m[:role] == "system" }
742
-
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)
698
+ @ui&.show_info("Compressing history (#{original_size} -> ~#{target_size} messages)...")
745
699
 
746
- # Get messages to compress (everything except system and recent)
747
- messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
700
+ # Find the system message (should be first)
701
+ system_msg = @messages.find { |m| m[:role] == "system" }
748
702
 
749
- if messages_to_compress.empty?
750
- progress.finish
751
- return
752
- end
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)
753
705
 
754
- # Create summary of compressed messages
755
- summary = summarize_messages(messages_to_compress)
706
+ # Get messages to compress (everything except system and recent)
707
+ messages_to_compress = @messages.reject { |m| m[:role] == "system" || recent_messages.include?(m) }
756
708
 
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
709
+ return if messages_to_compress.empty?
760
710
 
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
711
+ # Create summary of compressed messages
712
+ summary = summarize_messages(messages_to_compress)
765
713
 
766
- @messages = rebuilt_messages
714
+ # Rebuild messages array: [system, summary, recent_messages]
715
+ rebuilt_messages = [system_msg, summary, *recent_messages].compact
767
716
 
768
- final_size = @messages.size
717
+ @messages = rebuilt_messages
769
718
 
770
- # Finish progress and show completion message
771
- progress.finish
772
- puts "✅ Compressed conversation history (#{original_size} → #{final_size} messages)"
719
+ final_size = @messages.size
773
720
 
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
721
+ @ui&.show_info("Compressed (#{original_size} -> #{final_size} messages)")
783
722
  end
784
723
 
785
724
  def get_recent_messages_with_tool_pairs(messages, count)
@@ -908,77 +847,46 @@ module Clacky
908
847
  }
909
848
  end
910
849
 
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
-
850
+ def confirm_tool_use?(call)
925
851
  # Show preview first and check for errors
926
852
  preview_error = show_tool_preview(call)
927
853
 
928
- # If preview detected an error (e.g., edit with non-existent string),
929
- # auto-deny and provide detailed feedback
854
+ # If preview detected an error, auto-deny and provide feedback
930
855
  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
-
856
+ @ui&.show_warning("Tool call auto-denied due to preview error")
857
+ feedback = build_preview_error_feedback(call[:name], preview_error)
943
858
  return { approved: false, feedback: feedback }
944
859
  end
945
860
 
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)
861
+ # Request confirmation via UI
862
+ if @ui
863
+ prompt_text = format_tool_prompt(call)
864
+ result = @ui.request_confirmation(prompt_text, default: true)
952
865
 
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
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 }
956
874
  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 }
875
+ else
876
+ # Fallback: auto-approve if no UI
877
+ { approved: true, feedback: nil }
973
878
  end
879
+ end
974
880
 
975
- # "n"/"no" = denied without feedback
976
- if response_lower == "n" || response_lower == "no"
977
- 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]}"
978
889
  end
979
-
980
- # Any other input = denied with feedback
981
- { approved: false, feedback: response }
982
890
  end
983
891
 
984
892
  def format_tool_prompt(call)
@@ -1018,6 +926,8 @@ module Clacky
1018
926
  end
1019
927
 
1020
928
  def show_tool_preview(call)
929
+ return nil unless @ui
930
+
1021
931
  begin
1022
932
  args = JSON.parse(call[:arguments], symbolize_names: true)
1023
933
 
@@ -1028,22 +938,22 @@ module Clacky
1028
938
  when "edit"
1029
939
  preview_error = show_edit_preview(args)
1030
940
  when "shell", "safe_shell"
1031
- preview_error = show_shell_preview(args)
941
+ show_shell_preview(args)
1032
942
  else
1033
943
  # For other tools, show formatted arguments
1034
944
  tool = @tool_registry.get(call[:name]) rescue nil
1035
945
  if tool
1036
946
  formatted = tool.format_call(args) rescue "#{call[:name]}(...)"
1037
- puts "\nArgs: #{formatted}"
947
+ @ui&.show_tool_args(formatted)
1038
948
  else
1039
- puts "\nArgs: #{call[:arguments]}"
949
+ @ui&.show_tool_args(call[:arguments])
1040
950
  end
1041
951
  end
1042
952
 
1043
- return preview_error
953
+ preview_error
1044
954
  rescue JSON::ParserError
1045
- puts " Args: #{call[:arguments]}"
1046
- return nil
955
+ @ui&.show_tool_args(call[:arguments])
956
+ nil
1047
957
  end
1048
958
  end
1049
959
 
@@ -1051,17 +961,16 @@ module Clacky
1051
961
  path = args[:path] || args['path']
1052
962
  new_content = args[:content] || args['content'] || ""
1053
963
 
1054
- 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)
1055
966
 
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)
967
+ if is_new_file
968
+ @ui&.show_diff("", new_content, max_lines: 50)
1060
969
  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)
970
+ old_content = File.read(path)
971
+ @ui&.show_diff(old_content, new_content, max_lines: 50)
1064
972
  end
973
+ nil
1065
974
  end
1066
975
 
1067
976
  def show_edit_preview(args)
@@ -1069,20 +978,20 @@ module Clacky
1069
978
  old_string = args[:old_string] || args['old_string'] || ""
1070
979
  new_string = args[:new_string] || args['new_string'] || ""
1071
980
 
1072
- puts "\n📝 File: #{path || '(unknown)'}"
981
+ @ui&.show_file_edit_preview(path)
1073
982
 
1074
983
  if !path || path.empty?
1075
- puts " ⚠️ No file path provided"
984
+ @ui&.show_file_error("No file path provided")
1076
985
  return { error: "No file path provided for edit operation" }
1077
986
  end
1078
987
 
1079
988
  unless File.exist?(path)
1080
- puts " ⚠️ File not found: #{path}"
1081
- return { error: "File not found: #{path}" }
989
+ @ui&.show_file_error("File not found: #{path}")
990
+ return { error: "File not found: #{path}", path: path }
1082
991
  end
1083
992
 
1084
993
  if old_string.empty?
1085
- puts " ⚠️ No old_string provided (nothing to replace)"
994
+ @ui&.show_file_error("No old_string provided (nothing to replace)")
1086
995
  return { error: "No old_string provided (nothing to replace)" }
1087
996
  end
1088
997
 
@@ -1090,9 +999,19 @@ module Clacky
1090
999
 
1091
1000
  # Check if old_string exists in file
1092
1001
  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}"
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)
1096
1015
  return {
1097
1016
  error: "String to replace not found in file",
1098
1017
  path: path,
@@ -1101,34 +1020,20 @@ module Clacky
1101
1020
  end
1102
1021
 
1103
1022
  new_content = file_content.sub(old_string, new_string)
1104
- show_diff(file_content, new_content, max_lines: 50)
1023
+ @ui&.show_diff(file_content, new_content, max_lines: 50)
1105
1024
  nil # No error
1106
1025
  end
1107
1026
 
1108
1027
  def show_shell_preview(args)
1109
1028
  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"
1029
+ @ui&.show_shell_preview(command)
1030
+ nil
1126
1031
  end
1127
1032
 
1128
1033
  def build_success_result(call, result)
1129
1034
  # Try to get tool instance to use its format_result_for_llm method
1130
1035
  tool = @tool_registry.get(call[:name]) rescue nil
1131
-
1036
+
1132
1037
  formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
1133
1038
  # Tool provides a custom LLM-friendly format
1134
1039
  tool.format_result_for_llm(result)
@@ -1136,7 +1041,7 @@ module Clacky
1136
1041
  # Fallback: use the original result
1137
1042
  result
1138
1043
  end
1139
-
1044
+
1140
1045
  {
1141
1046
  id: call[:id],
1142
1047
  content: JSON.generate(formatted_result)