openclacky 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f86dc057b8fd69db07c40a5633ffe1350cf0ddb8509b6e26705464d9e127daa4
4
- data.tar.gz: 58d3e6b89e129d6c7ef7dbc57ff57e95ee8d6acd96186491daa8670b6031c3b2
3
+ metadata.gz: 34f843ea474327ea95f88685111f1a86149730503625c5e971de239e63764969
4
+ data.tar.gz: 85f2e93d791d3f774aa4f0d29177d4999fd91306f3c441f21d89c11ab37f15d0
5
5
  SHA512:
6
- metadata.gz: 4d724c3d9404faed0bead1fb3e417782f15a9d89dde03709696120df87230d7a6d33250684e96e99f13b87f3993bfffd53dc5b52c87c34e79844a171429685af
7
- data.tar.gz: 837b07953b38515d0c1bf0b6081a1fd10e7fffa46f9cbf1d1721744cabe494c5a018bcb1d0290e988782a534aad9ab24137978d7299d1908cb9fb248258de282
6
+ metadata.gz: 8bcb13d8cb5ee790ff698fc9bd0be4d2b366e1d16216db6a2f24e56c0dc1954c3b82349473b49cdee7c66ae732e3b684ee1d249d54dabe0f7d9cb0cf1f59873b
7
+ data.tar.gz: 866e043424ca831364baba5a842b5554cc1da2309e2935a0458edbead75c5e89f9cdf146506df12fc04c87d78c5613e3b2e812b13a816864af5861b1f815c788
data/.clackyrules CHANGED
@@ -25,6 +25,7 @@ It provides chat functionality and autonomous AI agent capabilities with tool us
25
25
  - **IMPORTANT**: All code comments must be written in English
26
26
  - Add descriptive comments for complex logic
27
27
  - Use clear, self-documenting code with English naming
28
+ - **IMPORTANT**: Always use inline `private` with method definitions (e.g., `private def method_name`). Do NOT use standalone `private` keyword
28
29
 
29
30
  ### Architecture Patterns
30
31
  - Tools inherit from `Clacky::Tools::Base`
@@ -41,6 +42,9 @@ It provides chat functionality and autonomous AI agent capabilities with tool us
41
42
  - Maintain good test coverage
42
43
  - When modifying existing functionality, ensure related tests still pass
43
44
  - When adding new features, consider adding corresponding tests
45
+ - **IMPORTANT**: When developing new features, write RSpec tests as needed and ensure they pass
46
+ - **DO NOT** write custom test scripts unless explicitly requested by the user
47
+ - **DO NOT** create markdown documentation unless explicitly requested by the user
44
48
 
45
49
  ### Tool Development
46
50
  When adding new tools:
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenClacky
2
2
 
3
- A command-line interface for interacting with AI models. OpenClacky supports OpenAI-compatible APIs, making it easy to chat with various AI models directly from your terminal.
3
+ OpenClacky = Lovable + Supabase
4
4
 
5
5
  ## Features
6
6
 
data/lib/clacky/agent.rb CHANGED
@@ -4,18 +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
13
-
14
- # Pricing per 1M tokens (approximate - adjust based on actual model)
15
- PRICING = {
16
- input: 0.50, # $0.50 per 1M input tokens
17
- output: 1.50 # $1.50 per 1M output tokens
18
- }.freeze
13
+ :cache_stats, :cost_source
19
14
 
20
15
  # System prompt for the coding agent
21
16
  SYSTEM_PROMPT = <<~PROMPT.freeze
@@ -76,6 +71,9 @@ module Clacky
76
71
  @working_dir = working_dir || Dir.pwd
77
72
  @created_at = Time.now.iso8601
78
73
  @total_tasks = 0
74
+ @cost_source = :estimated # Track whether cost is from API or estimated
75
+ @task_cost_source = :estimated # Track cost source for current task
76
+ @previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
79
77
 
80
78
  # Register built-in tools
81
79
  register_builtin_tools
@@ -131,8 +129,10 @@ module Clacky
131
129
  @hooks.add(event, &block)
132
130
  end
133
131
 
134
- def run(user_input, &block)
132
+ def run(user_input, images: [], &block)
135
133
  @start_time = Time.now
134
+ @task_cost_source = :estimated # Reset for new task
135
+ @previous_total_tokens = 0 # Reset token tracking for new task
136
136
 
137
137
  # Add system prompt as the first message if this is the first run
138
138
  if @messages.empty?
@@ -147,7 +147,9 @@ module Clacky
147
147
  @messages << system_message
148
148
  end
149
149
 
150
- @messages << { role: "user", content: user_input }
150
+ # Format user message with images if provided
151
+ user_content = format_user_content(user_input, images)
152
+ @messages << { role: "user", content: user_content }
151
153
  @total_tasks += 1
152
154
 
153
155
  emit_event(:on_start, { input: user_input }, &block)
@@ -221,14 +223,14 @@ module Clacky
221
223
  # @param status [Symbol] Status of the last task: :success, :error, or :interrupted
222
224
  # @param error_message [String] Error message if status is :error
223
225
  def to_session_data(status: :success, error_message: nil)
224
- # Get first real user message for preview (skip compressed system messages)
225
- first_user_msg = @messages.find do |m|
226
+ # Get last real user message for preview (skip compressed system messages)
227
+ last_user_msg = @messages.reverse.find do |m|
226
228
  m[:role] == "user" && !m[:content].to_s.start_with?("[SYSTEM]")
227
229
  end
228
230
 
229
- # Extract preview text from first user message
230
- first_message_preview = if first_user_msg
231
- content = first_user_msg[:content]
231
+ # Extract preview text from last user message
232
+ last_message_preview = if last_user_msg
233
+ content = last_user_msg[:content]
232
234
  if content.is_a?(String)
233
235
  # Truncate to 100 characters for preview
234
236
  content.length > 100 ? "#{content[0..100]}..." : content
@@ -270,7 +272,7 @@ module Clacky
270
272
  },
271
273
  stats: stats_data,
272
274
  messages: @messages,
273
- first_user_message: first_message_preview
275
+ first_user_message: last_message_preview
274
276
  }
275
277
  end
276
278
 
@@ -613,9 +615,35 @@ module Clacky
613
615
  end
614
616
 
615
617
  def track_cost(usage)
616
- input_cost = (usage[:prompt_tokens] / 1_000_000.0) * PRICING[:input]
617
- output_cost = (usage[:completion_tokens] / 1_000_000.0) * PRICING[:output]
618
- @total_cost += input_cost + output_cost
618
+ # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
619
+ iteration_cost = nil
620
+ if usage[:api_cost]
621
+ @total_cost += usage[:api_cost]
622
+ @cost_source = :api
623
+ @task_cost_source = :api
624
+ iteration_cost = usage[:api_cost]
625
+ puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
626
+ else
627
+ # Priority 2: Calculate from tokens using ModelPricing
628
+ result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
629
+ cost = result[:cost]
630
+ pricing_source = result[:source]
631
+
632
+ @total_cost += cost
633
+ iteration_cost = cost
634
+ # Map pricing source to cost source: :price or :default
635
+ @cost_source = pricing_source
636
+ @task_cost_source = pricing_source
637
+
638
+ if @config.verbose
639
+ 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}"
642
+ end
643
+ end
644
+
645
+ # Display token usage statistics for this iteration
646
+ display_iteration_tokens(usage, iteration_cost)
619
647
 
620
648
  # Track cache usage statistics
621
649
  @cache_stats[:total_requests] += 1
@@ -630,6 +658,66 @@ module Clacky
630
658
  end
631
659
  end
632
660
 
661
+ # Display token usage for current iteration
662
+ private def display_iteration_tokens(usage, cost)
663
+ prompt_tokens = usage[:prompt_tokens] || 0
664
+ completion_tokens = usage[:completion_tokens] || 0
665
+ total_tokens = usage[:total_tokens] || (prompt_tokens + completion_tokens)
666
+ cache_write = usage[:cache_creation_input_tokens] || 0
667
+ cache_read = usage[:cache_read_input_tokens] || 0
668
+
669
+ # Calculate token delta from previous iteration
670
+ delta_tokens = total_tokens - @previous_total_tokens
671
+ @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
+
712
+ # Cost for this iteration
713
+ if cost
714
+ token_info << "Cost: $#{cost.round(6)}"
715
+ end
716
+
717
+ # Display with color
718
+ puts pastel.dim(" [Tokens] #{token_info.join(' | ')}")
719
+ end
720
+
633
721
  def compress_messages_if_needed
634
722
  # Check if compression is enabled
635
723
  return unless @config.enable_compression
@@ -1038,9 +1126,20 @@ module Clacky
1038
1126
  end
1039
1127
 
1040
1128
  def build_success_result(call, result)
1129
+ # Try to get tool instance to use its format_result_for_llm method
1130
+ tool = @tool_registry.get(call[:name]) rescue nil
1131
+
1132
+ formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
1133
+ # Tool provides a custom LLM-friendly format
1134
+ tool.format_result_for_llm(result)
1135
+ else
1136
+ # Fallback: use the original result
1137
+ result
1138
+ end
1139
+
1041
1140
  {
1042
1141
  id: call[:id],
1043
- content: JSON.generate(result)
1142
+ content: JSON.generate(formatted_result)
1044
1143
  }
1045
1144
  end
1046
1145
 
@@ -1081,6 +1180,7 @@ module Clacky
1081
1180
  iterations: @iterations,
1082
1181
  duration_seconds: Time.now - @start_time,
1083
1182
  total_cost_usd: @total_cost.round(4),
1183
+ cost_source: @task_cost_source, # Add cost source for this task
1084
1184
  cache_stats: @cache_stats,
1085
1185
  messages: @messages,
1086
1186
  error: error
@@ -1115,5 +1215,76 @@ module Clacky
1115
1215
  @tool_registry.register(Tools::TodoManager.new)
1116
1216
  @tool_registry.register(Tools::RunProject.new)
1117
1217
  end
1218
+
1219
+ # Format user content with optional images
1220
+ # @param text [String] User's text input
1221
+ # @param images [Array<String>] Array of image file paths
1222
+ # @return [String|Array] String if no images, Array with text and image_url objects if images present
1223
+ def format_user_content(text, images)
1224
+ return text if images.nil? || images.empty?
1225
+
1226
+ content = []
1227
+ content << { type: "text", text: text } unless text.nil? || text.empty?
1228
+
1229
+ images.each do |image_path|
1230
+ image_url = image_path_to_data_url(image_path)
1231
+ content << { type: "image_url", image_url: { url: image_url } }
1232
+ end
1233
+
1234
+ content
1235
+ end
1236
+
1237
+ # Convert image file path to base64 data URL
1238
+ # @param path [String] File path to image
1239
+ # @return [String] base64 data URL (e.g., "data:image/png;base64,...")
1240
+ def image_path_to_data_url(path)
1241
+ unless File.exist?(path)
1242
+ raise ArgumentError, "Image file not found: #{path}"
1243
+ end
1244
+
1245
+ # Read file as binary
1246
+ image_data = File.binread(path)
1247
+
1248
+ # Detect MIME type from file extension or content
1249
+ mime_type = detect_image_mime_type(path, image_data)
1250
+
1251
+ # Encode to base64
1252
+ base64_data = Base64.strict_encode64(image_data)
1253
+
1254
+ "data:#{mime_type};base64,#{base64_data}"
1255
+ end
1256
+
1257
+ # Detect image MIME type
1258
+ # @param path [String] File path
1259
+ # @param data [String] Binary image data
1260
+ # @return [String] MIME type (e.g., "image/png")
1261
+ def detect_image_mime_type(path, data)
1262
+ # Try to detect from file extension first
1263
+ ext = File.extname(path).downcase
1264
+ case ext
1265
+ when ".png"
1266
+ "image/png"
1267
+ when ".jpg", ".jpeg"
1268
+ "image/jpeg"
1269
+ when ".gif"
1270
+ "image/gif"
1271
+ when ".webp"
1272
+ "image/webp"
1273
+ else
1274
+ # Try to detect from file signature (magic bytes)
1275
+ if data.start_with?("\x89PNG".b)
1276
+ "image/png"
1277
+ elsif data.start_with?("\xFF\xD8\xFF".b)
1278
+ "image/jpeg"
1279
+ elsif data.start_with?("GIF87a".b) || data.start_with?("GIF89a".b)
1280
+ "image/gif"
1281
+ elsif data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
1282
+ "image/webp"
1283
+ else
1284
+ # Default to png if unknown
1285
+ "image/png"
1286
+ end
1287
+ end
1288
+ end
1118
1289
  end
1119
1290
  end
data/lib/clacky/cli.rb CHANGED
@@ -14,41 +14,15 @@ module Clacky
14
14
  true
15
15
  end
16
16
 
17
- desc "chat [MESSAGE]", "Start a chat with Claude or send a single message"
18
- long_desc <<-LONGDESC
19
- Start an interactive chat session with Claude AI.
20
-
21
- If MESSAGE is provided, send it as a single message and exit.
22
- If no MESSAGE is provided, start an interactive chat session.
23
-
24
- Examples:
25
- $ clacky chat "What is Ruby?"
26
- $ clacky chat
27
- LONGDESC
28
- option :model, type: :string, desc: "Model to use (default from config)"
29
- def chat(message = nil)
30
- config = Clacky::Config.load
31
-
32
- unless config.api_key
33
- say "Error: API key not found. Please run 'clacky config set' first.", :red
34
- exit 1
35
- end
36
-
37
- if message
38
- # Single message mode
39
- send_single_message(message, config)
40
- else
41
- # Interactive mode
42
- start_interactive_chat(config)
43
- end
44
- end
17
+ # Set agent as the default command
18
+ default_task :agent
45
19
 
46
20
  desc "version", "Show clacky version"
47
21
  def version
48
22
  say "Clacky version #{Clacky::VERSION}"
49
23
  end
50
24
 
51
- desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use"
25
+ desc "agent [MESSAGE]", "Run agent in interactive mode with autonomous tool use (default)"
52
26
  long_desc <<-LONGDESC
53
27
  Run an AI agent in interactive mode that can autonomously use tools to complete tasks.
54
28
 
@@ -133,7 +107,7 @@ module Clacky
133
107
 
134
108
  begin
135
109
  # Always run in interactive mode
136
- run_agent_interactive(agent, working_dir, agent_config, message, session_manager)
110
+ run_agent_interactive(agent, working_dir, agent_config, message, session_manager, client)
137
111
  rescue StandardError => e
138
112
  # Save session on error
139
113
  if session_manager
@@ -159,44 +133,48 @@ module Clacky
159
133
  end
160
134
  end
161
135
 
162
- desc "tools", "List available tools"
163
- option :category, type: :string, desc: "Filter by category"
164
- def tools
165
- registry = ToolRegistry.new
166
-
167
- registry.register(Tools::Shell.new)
168
- registry.register(Tools::FileReader.new)
169
- registry.register(Tools::Write.new)
170
- registry.register(Tools::Edit.new)
171
- registry.register(Tools::Glob.new)
172
- registry.register(Tools::Grep.new)
173
- registry.register(Tools::WebSearch.new)
174
- registry.register(Tools::WebFetch.new)
175
-
176
- say "\n📦 Available Tools:\n\n", :green
177
-
178
- tools_to_show = if options[:category]
179
- registry.by_category(options[:category])
180
- else
181
- registry.all
182
- end
183
-
184
- tools_to_show.each do |tool|
185
- say " #{tool.name}", :cyan
186
- say " #{tool.description}", :white
187
- say " Category: #{tool.category}", :yellow
188
-
189
- if tool.parameters[:properties]
190
- say " Parameters:", :yellow
191
- tool.parameters[:properties].each do |name, spec|
192
- required = tool.parameters[:required]&.include?(name.to_s) ? " (required)" : ""
193
- say " - #{name}: #{spec[:description]}#{required}", :white
194
- end
195
- end
196
- say ""
197
- end
198
-
199
- say "Total: #{tools_to_show.size} tools\n", :green
136
+ desc "price", "Show pricing information for AI models"
137
+ def price
138
+ say "\n💰 Model Pricing Information\n\n", :green
139
+
140
+ say "Clacky supports three pricing modes when calculating API costs:\n\n", :white
141
+
142
+ say " 1. ", :cyan
143
+ say "API-provided cost", :bold
144
+ say " (", :white
145
+ say ":api", :yellow
146
+ say ")", :white
147
+ say "\n The most accurate - uses actual cost data from the API response", :white
148
+ say "\n Supported by: OpenRouter, LiteLLM, and other compatible proxies\n\n"
149
+
150
+ say " 2. ", :cyan
151
+ say "Model-specific pricing", :bold
152
+ say " (", :white
153
+ say ":price", :yellow
154
+ say ")", :white
155
+ say "\n Uses official pricing from model providers (Claude models)", :white
156
+ say "\n Includes tiered pricing and prompt caching discounts\n\n"
157
+
158
+ say " 3. ", :cyan
159
+ say "Default fallback pricing", :bold
160
+ say " (", :white
161
+ say ":default", :yellow
162
+ say ")", :white
163
+ say "\n Conservative estimates for unknown models", :white
164
+ say "\n Input: $0.50/MTok, Output: $1.50/MTok\n\n"
165
+
166
+ say "Priority order: API cost > Model pricing > Default pricing\n\n", :yellow
167
+
168
+ say "Supported models with official pricing:\n", :green
169
+ say " • claude-opus-4.5\n", :cyan
170
+ say " • claude-sonnet-4.5\n", :cyan
171
+ say " • claude-haiku-4.5\n", :cyan
172
+ say " • claude-3-5-sonnet-20241022\n", :cyan
173
+ say " claude-3-5-sonnet-20240620\n", :cyan
174
+ say " • claude-3-5-haiku-20241022\n\n", :cyan
175
+
176
+ say "For detailed pricing information, visit:\n", :white
177
+ say "https://www.anthropic.com/pricing\n\n", :blue
200
178
  end
201
179
 
202
180
  no_commands do
@@ -353,7 +331,7 @@ module Clacky
353
331
  end
354
332
  end
355
333
 
356
- def run_agent_interactive(agent, working_dir, agent_config, initial_message = nil, session_manager = nil)
334
+ def run_agent_interactive(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil)
357
335
  # Store agent as instance variable for access in display methods
358
336
  @current_agent = agent
359
337
 
@@ -393,10 +371,13 @@ module Clacky
393
371
 
394
372
  # Process initial message if provided
395
373
  current_message = initial_message
374
+ current_images = []
396
375
 
397
376
  loop do
398
377
  # Get message from user if not provided
399
378
  unless current_message && !current_message.strip.empty?
379
+ # Only show newline separator if we've completed tasks
380
+ # (but not right after /clear since we just showed a message)
400
381
  say "\n" if total_tasks > 0
401
382
 
402
383
  # Show status bar before input
@@ -408,24 +389,87 @@ module Clacky
408
389
  cost: total_cost
409
390
  )
410
391
 
411
- # Use enhanced prompt with "You:" prefix
412
- result = prompt.read_input(prefix: "You:")
392
+ # Use enhanced prompt with "" prefix
393
+ result = prompt.read_input(prefix: "") do |display_lines|
394
+ # Shift+Tab pressed - toggle mode and update status bar
395
+ if agent_config.permission_mode == :confirm_safes
396
+ agent_config.permission_mode = :auto_approve
397
+ else
398
+ agent_config.permission_mode = :confirm_safes
399
+ end
400
+
401
+ # Update status bar (it's above the input box)
402
+ # display_lines includes the final newline, so we need display_lines moves to reach status bar
403
+ print "\e[#{display_lines}A" # Move up to status bar line
404
+ print "\r\e[2K" # Clear the status bar line
405
+
406
+ # Redisplay status bar with new mode (puts adds newline, cursor moves to next line)
407
+ statusbar.display(
408
+ working_dir: working_dir,
409
+ mode: agent_config.permission_mode.to_s,
410
+ model: agent_config.model,
411
+ tasks: total_tasks,
412
+ cost: total_cost
413
+ )
414
+
415
+ # Move back down to original position (display_lines - 1 because puts moved us down 1)
416
+ print "\e[#{display_lines - 1}B"
417
+ end
413
418
 
414
- # EnhancedPrompt returns { text: String, images: Array } or nil
415
- # For now, we only use the text part
416
- current_message = result.nil? ? nil : result[:text]
419
+ # EnhancedPrompt returns:
420
+ # - { text: String, images: Array } for normal input
421
+ # - { command: Symbol } for commands
422
+ # - nil on EOF
423
+ if result.nil?
424
+ current_message = nil
425
+ current_images = []
426
+ break
427
+ elsif result[:command]
428
+ # Handle commands
429
+ case result[:command]
430
+ when :clear
431
+ # Clear session by creating a new agent
432
+ agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir)
433
+ @current_agent = agent
434
+ total_tasks = 0
435
+ total_cost = 0.0
436
+ ui_formatter.info("Session cleared. Starting fresh.")
437
+ current_message = nil
438
+ current_images = []
439
+ next
440
+ when :exit
441
+ current_message = nil
442
+ current_images = []
443
+ break
444
+ end
445
+ else
446
+ # Normal input with text and optional images
447
+ current_message = result[:text]
448
+ current_images = result[:images] || []
449
+ end
417
450
 
418
451
  break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
419
- next if current_message.strip.empty?
452
+ next if current_message.strip.empty? && current_images.empty?
420
453
 
421
454
  # Display user's message after input
422
455
  ui_formatter.user_message(current_message)
456
+
457
+ # Display image info if images were pasted (without extra newline)
458
+ if current_images.any?
459
+ current_images.each_with_index do |img_path, idx|
460
+ filename = File.basename(img_path)
461
+ say " 📎 Image #{idx + 1}: #{filename}", :cyan
462
+ end
463
+ puts # Add newline after all images
464
+ else
465
+ puts # Add newline after user message if no images
466
+ end
423
467
  end
424
468
 
425
469
  total_tasks += 1
426
470
 
427
471
  begin
428
- result = agent.run(current_message) do |event|
472
+ result = agent.run(current_message, images: current_images) do |event|
429
473
  display_agent_event(event)
430
474
  end
431
475
 
@@ -442,6 +486,7 @@ module Clacky
442
486
  cost: result[:total_cost_usd].round(4),
443
487
  total_tasks: total_tasks,
444
488
  total_cost: total_cost.round(4),
489
+ cost_source: result[:cost_source],
445
490
  cache_stats: result[:cache_stats]
446
491
  )
447
492
  rescue Clacky::AgentInterrupted
@@ -470,12 +515,14 @@ module Clacky
470
515
  say "\nOr you can continue with a new task or type 'exit' to quit.", :yellow
471
516
  end
472
517
 
473
- # Clear current_message to prompt for next input
518
+ # Clear current_message and current_images to prompt for next input
474
519
  current_message = nil
520
+ current_images = []
475
521
  end
476
522
 
477
- # Save final session state
478
- if session_manager
523
+ # Save final session state only if there were actual tasks
524
+ # Don't save empty sessions where user just started and exited
525
+ if session_manager && total_tasks > 0
479
526
  session_manager.save(agent.to_session_data)
480
527
  end
481
528
 
@@ -632,68 +679,6 @@ module Clacky
632
679
  @pastel ||= Pastel.new
633
680
  end
634
681
  end
635
-
636
- private
637
-
638
- def send_single_message(message, config)
639
- spinner = TTY::Spinner.new("[:spinner] Thinking...", format: :dots)
640
- spinner.auto_spin
641
-
642
- client = Clacky::Client.new(config.api_key, base_url: config.base_url)
643
- response = client.send_message(message, model: options[:model] || config.model)
644
-
645
- spinner.success("Done!")
646
- say "\n#{response}", :cyan
647
- rescue StandardError => e
648
- spinner.error("Failed!")
649
- say "Error: #{e.message}", :red
650
- exit 1
651
- end
652
-
653
- def start_interactive_chat(config)
654
- say "Starting interactive chat with Claude...", :green
655
- say "Type 'exit' or 'quit' to end the session.\n\n", :yellow
656
-
657
- conversation = Clacky::Conversation.new(
658
- config.api_key,
659
- model: options[:model] || config.model,
660
- base_url: config.base_url
661
- )
662
-
663
- # Use TTY::Prompt for input
664
- tty_prompt = TTY::Prompt.new(interrupt: :exit)
665
-
666
- loop do
667
- # Use TTY::Prompt for better input handling
668
- begin
669
- message = tty_prompt.ask("You:", required: false) do |q|
670
- q.modify :strip
671
- end
672
- rescue TTY::Reader::InputInterrupt
673
- # Handle Ctrl+C
674
- puts
675
- break
676
- end
677
-
678
- break if message.nil? || %w[exit quit].include?(message&.downcase&.strip)
679
- next if message.nil? || message.strip.empty?
680
-
681
- spinner = TTY::Spinner.new("[:spinner] Claude is thinking...", format: :dots)
682
- spinner.auto_spin
683
-
684
- begin
685
- response = conversation.send_message(message)
686
- spinner.success("Claude:")
687
- say response, :cyan
688
- say "\n"
689
- rescue StandardError => e
690
- spinner.error("Error!")
691
- say "Error: #{e.message}", :red
692
- end
693
- end
694
-
695
- say "\nGoodbye!", :green
696
- end
697
682
  end
698
683
 
699
684
  class ConfigCommand < Thor