openclacky 0.5.5 → 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: cc218b590b40d1a2301ef91a73b5a4ded7d6891da5b42eea62f4fa8bce200292
4
- data.tar.gz: e828ac9fd724d0097f7557401127c513f35138c31d76a06e07c8c70db049cb1c
3
+ metadata.gz: 34f843ea474327ea95f88685111f1a86149730503625c5e971de239e63764969
4
+ data.tar.gz: 85f2e93d791d3f774aa4f0d29177d4999fd91306f3c441f21d89c11ab37f15d0
5
5
  SHA512:
6
- metadata.gz: 91db158259b243438e5af2fa26d257f30c4c5f755383e4bde6e20b71cf9e5481bc5b2d91cd5c63e5916ed8d6e373050e60dd16fd4b3a0231073702b75ad2702a
7
- data.tar.gz: '0940f53a61a33f22de700c8609ede85e7d937f096688374eedef072758372f00e87fa6327050d323a7f5f3d869b487ff39bb942fe56534afe4a906b3b6cde494'
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,6 +4,7 @@ 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
@@ -72,6 +73,7 @@ module Clacky
72
73
  @total_tasks = 0
73
74
  @cost_source = :estimated # Track whether cost is from API or estimated
74
75
  @task_cost_source = :estimated # Track cost source for current task
76
+ @previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
75
77
 
76
78
  # Register built-in tools
77
79
  register_builtin_tools
@@ -127,9 +129,10 @@ module Clacky
127
129
  @hooks.add(event, &block)
128
130
  end
129
131
 
130
- def run(user_input, &block)
132
+ def run(user_input, images: [], &block)
131
133
  @start_time = Time.now
132
134
  @task_cost_source = :estimated # Reset for new task
135
+ @previous_total_tokens = 0 # Reset token tracking for new task
133
136
 
134
137
  # Add system prompt as the first message if this is the first run
135
138
  if @messages.empty?
@@ -144,7 +147,9 @@ module Clacky
144
147
  @messages << system_message
145
148
  end
146
149
 
147
- @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 }
148
153
  @total_tasks += 1
149
154
 
150
155
  emit_event(:on_start, { input: user_input }, &block)
@@ -611,22 +616,25 @@ module Clacky
611
616
 
612
617
  def track_cost(usage)
613
618
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
619
+ iteration_cost = nil
614
620
  if usage[:api_cost]
615
621
  @total_cost += usage[:api_cost]
616
622
  @cost_source = :api
617
623
  @task_cost_source = :api
624
+ iteration_cost = usage[:api_cost]
618
625
  puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
619
626
  else
620
627
  # Priority 2: Calculate from tokens using ModelPricing
621
628
  result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
622
629
  cost = result[:cost]
623
630
  pricing_source = result[:source]
624
-
631
+
625
632
  @total_cost += cost
633
+ iteration_cost = cost
626
634
  # Map pricing source to cost source: :price or :default
627
635
  @cost_source = pricing_source
628
636
  @task_cost_source = pricing_source
629
-
637
+
630
638
  if @config.verbose
631
639
  source_label = pricing_source == :price ? "model pricing" : "default pricing"
632
640
  puts "[DEBUG] Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}"
@@ -634,6 +642,9 @@ module Clacky
634
642
  end
635
643
  end
636
644
 
645
+ # Display token usage statistics for this iteration
646
+ display_iteration_tokens(usage, iteration_cost)
647
+
637
648
  # Track cache usage statistics
638
649
  @cache_stats[:total_requests] += 1
639
650
 
@@ -647,6 +658,66 @@ module Clacky
647
658
  end
648
659
  end
649
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
+
650
721
  def compress_messages_if_needed
651
722
  # Check if compression is enabled
652
723
  return unless @config.enable_compression
@@ -1055,9 +1126,20 @@ module Clacky
1055
1126
  end
1056
1127
 
1057
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
+
1058
1140
  {
1059
1141
  id: call[:id],
1060
- content: JSON.generate(result)
1142
+ content: JSON.generate(formatted_result)
1061
1143
  }
1062
1144
  end
1063
1145
 
@@ -1133,5 +1215,76 @@ module Clacky
1133
1215
  @tool_registry.register(Tools::TodoManager.new)
1134
1216
  @tool_registry.register(Tools::RunProject.new)
1135
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
1136
1289
  end
1137
1290
  end
data/lib/clacky/cli.rb CHANGED
@@ -107,7 +107,7 @@ module Clacky
107
107
 
108
108
  begin
109
109
  # Always run in interactive mode
110
- run_agent_interactive(agent, working_dir, agent_config, message, session_manager)
110
+ run_agent_interactive(agent, working_dir, agent_config, message, session_manager, client)
111
111
  rescue StandardError => e
112
112
  # Save session on error
113
113
  if session_manager
@@ -331,7 +331,7 @@ module Clacky
331
331
  end
332
332
  end
333
333
 
334
- 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)
335
335
  # Store agent as instance variable for access in display methods
336
336
  @current_agent = agent
337
337
 
@@ -371,10 +371,13 @@ module Clacky
371
371
 
372
372
  # Process initial message if provided
373
373
  current_message = initial_message
374
+ current_images = []
374
375
 
375
376
  loop do
376
377
  # Get message from user if not provided
377
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)
378
381
  say "\n" if total_tasks > 0
379
382
 
380
383
  # Show status bar before input
@@ -387,23 +390,86 @@ module Clacky
387
390
  )
388
391
 
389
392
  # Use enhanced prompt with "❯" prefix
390
- result = prompt.read_input(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
391
418
 
392
- # EnhancedPrompt returns { text: String, images: Array } or nil
393
- # For now, we only use the text part
394
- 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
395
450
 
396
451
  break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
397
- next if current_message.strip.empty?
452
+ next if current_message.strip.empty? && current_images.empty?
398
453
 
399
454
  # Display user's message after input
400
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
401
467
  end
402
468
 
403
469
  total_tasks += 1
404
470
 
405
471
  begin
406
- result = agent.run(current_message) do |event|
472
+ result = agent.run(current_message, images: current_images) do |event|
407
473
  display_agent_event(event)
408
474
  end
409
475
 
@@ -449,12 +515,14 @@ module Clacky
449
515
  say "\nOr you can continue with a new task or type 'exit' to quit.", :yellow
450
516
  end
451
517
 
452
- # Clear current_message to prompt for next input
518
+ # Clear current_message and current_images to prompt for next input
453
519
  current_message = nil
520
+ current_images = []
454
521
  end
455
522
 
456
- # Save final session state
457
- 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
458
526
  session_manager.save(agent.to_session_data)
459
527
  end
460
528
 
@@ -21,7 +21,7 @@ module Clacky
21
21
  # Start background thread to update elapsed time
22
22
  @update_thread = Thread.new do
23
23
  while @running
24
- sleep 1
24
+ sleep 0.1
25
25
  update if @running
26
26
  end
27
27
  end
@@ -25,29 +25,37 @@ module Clacky
25
25
  }
26
26
 
27
27
  def execute(path:, max_lines: 1000)
28
- unless File.exist?(path)
28
+ # Expand ~ to home directory
29
+ expanded_path = File.expand_path(path)
30
+
31
+ unless File.exist?(expanded_path)
29
32
  return {
30
- path: path,
33
+ path: expanded_path,
31
34
  content: nil,
32
- error: "File not found: #{path}"
35
+ error: "File not found: #{expanded_path}"
33
36
  }
34
37
  end
35
38
 
36
- unless File.file?(path)
39
+ # If path is a directory, list its first-level contents (similar to filetree)
40
+ if File.directory?(expanded_path)
41
+ return list_directory_contents(expanded_path)
42
+ end
43
+
44
+ unless File.file?(expanded_path)
37
45
  return {
38
- path: path,
46
+ path: expanded_path,
39
47
  content: nil,
40
- error: "Path is not a file: #{path}"
48
+ error: "Path is not a file: #{expanded_path}"
41
49
  }
42
50
  end
43
51
 
44
52
  begin
45
- lines = File.readlines(path).first(max_lines)
53
+ lines = File.readlines(expanded_path).first(max_lines)
46
54
  content = lines.join
47
- truncated = File.readlines(path).size > max_lines
55
+ truncated = File.readlines(expanded_path).size > max_lines
48
56
 
49
57
  {
50
- path: path,
58
+ path: expanded_path,
51
59
  content: content,
52
60
  lines_read: lines.size,
53
61
  truncated: truncated,
@@ -55,7 +63,7 @@ module Clacky
55
63
  }
56
64
  rescue StandardError => e
57
65
  {
58
- path: path,
66
+ path: expanded_path,
59
67
  content: nil,
60
68
  error: "Error reading file: #{e.message}"
61
69
  }
@@ -70,10 +78,65 @@ module Clacky
70
78
  def format_result(result)
71
79
  return result[:error] if result[:error]
72
80
 
81
+ # Handle directory listing
82
+ if result[:is_directory] || result['is_directory']
83
+ entries = result[:entries_count] || result['entries_count'] || 0
84
+ dirs = result[:directories_count] || result['directories_count'] || 0
85
+ files = result[:files_count] || result['files_count'] || 0
86
+ return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
87
+ end
88
+
89
+ # Handle file reading
73
90
  lines = result[:lines_read] || result['lines_read'] || 0
74
91
  truncated = result[:truncated] || result['truncated']
75
92
  "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
76
93
  end
94
+
95
+ private
96
+
97
+ # List first-level directory contents (files and directories)
98
+ def list_directory_contents(path)
99
+ begin
100
+ entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
101
+
102
+ # Separate files and directories
103
+ files = []
104
+ directories = []
105
+
106
+ entries.each do |entry|
107
+ full_path = File.join(path, entry)
108
+ if File.directory?(full_path)
109
+ directories << entry + "/"
110
+ else
111
+ files << entry
112
+ end
113
+ end
114
+
115
+ # Sort directories and files separately, then combine
116
+ directories.sort!
117
+ files.sort!
118
+ all_entries = directories + files
119
+
120
+ # Format as a tree-like structure
121
+ content = all_entries.map { |entry| " #{entry}" }.join("\n")
122
+
123
+ {
124
+ path: path,
125
+ content: "Directory listing:\n#{content}",
126
+ entries_count: all_entries.size,
127
+ directories_count: directories.size,
128
+ files_count: files.size,
129
+ is_directory: true,
130
+ error: nil
131
+ }
132
+ rescue StandardError => e
133
+ {
134
+ path: path,
135
+ content: nil,
136
+ error: "Error reading directory: #{e.message}"
137
+ }
138
+ end
139
+ end
77
140
  end
78
141
  end
79
142
  end
@@ -256,6 +256,50 @@ module Clacky
256
256
  end
257
257
  end
258
258
 
259
+ # Format result for LLM consumption - return a compact version to save tokens
260
+ def format_result_for_llm(result)
261
+ # If there's an error, return it as-is
262
+ return result if result[:error]
263
+
264
+ # Build a compact summary with file list and sample matches
265
+ compact = {
266
+ summary: {
267
+ total_matches: result[:total_matches],
268
+ files_with_matches: result[:files_with_matches],
269
+ files_searched: result[:files_searched],
270
+ truncated: result[:truncated],
271
+ truncation_reason: result[:truncation_reason]
272
+ }
273
+ }
274
+
275
+ # Include list of files with match counts
276
+ if result[:results] && !result[:results].empty?
277
+ compact[:files] = result[:results].map do |file_result|
278
+ {
279
+ file: file_result[:file],
280
+ match_count: file_result[:matches].length
281
+ }
282
+ end
283
+
284
+ # Include sample matches (first 2 matches from first 3 files) for context
285
+ sample_results = result[:results].take(3)
286
+ compact[:sample_matches] = sample_results.map do |file_result|
287
+ {
288
+ file: file_result[:file],
289
+ matches: file_result[:matches].take(2).map do |match|
290
+ {
291
+ line_number: match[:line_number],
292
+ line: match[:line]
293
+ # Omit context to save space - it's rarely needed by LLM
294
+ }
295
+ end
296
+ }
297
+ end
298
+ end
299
+
300
+ compact
301
+ end
302
+
259
303
  private
260
304
 
261
305
  # Find .gitignore file in the search path or parent directories
@@ -9,7 +9,7 @@ require "base64"
9
9
  module Clacky
10
10
  module UI
11
11
  # Enhanced input prompt with multi-line support and image paste
12
- #
12
+ #
13
13
  # Features:
14
14
  # - Shift+Enter: Add new line
15
15
  # - Enter: Submit message
@@ -20,6 +20,7 @@ module Clacky
20
20
 
21
21
  def initialize
22
22
  @pastel = Pastel.new
23
+ @formatter = Formatter.new
23
24
  @images = [] # Array of image file paths
24
25
  @paste_counter = 0 # Counter for paste operations
25
26
  @paste_placeholders = {} # Map of placeholder text to actual pasted content
@@ -29,8 +30,12 @@ module Clacky
29
30
 
30
31
  # Read user input with enhanced features
31
32
  # @param prefix [String] Prompt prefix (default: "❯")
32
- # @return [Hash, nil] { text: String, images: Array } or nil on EOF
33
- def read_input(prefix: "❯")
33
+ # @param block [Proc] Optional callback when Shift+Tab is pressed (receives display_lines)
34
+ # @return [Hash, nil] Returns:
35
+ # - { text: String, images: Array } for normal input
36
+ # - { command: Symbol } for commands (:clear, :exit)
37
+ # - nil on EOF
38
+ def read_input(prefix: "❯", &block)
34
39
  @images = []
35
40
  lines = []
36
41
  cursor_pos = 0
@@ -47,18 +52,18 @@ module Clacky
47
52
  rescue Interrupt
48
53
  return nil
49
54
  end
50
-
55
+
51
56
  # Handle buffered rapid input (system paste detection)
52
57
  if key.is_a?(Hash) && key[:type] == :rapid_input
53
58
  pasted_text = key[:text]
54
- pasted_lines = pasted_text.split("\n")
55
-
59
+ pasted_lines = pasted_text.split(/\r\n|\r|\n/)
60
+
56
61
  if pasted_lines.size > 1
57
62
  # Multi-line rapid input - use placeholder for display
58
63
  @paste_counter += 1
59
64
  placeholder = "[##{@paste_counter} Paste Text]"
60
65
  @paste_placeholders[placeholder] = pasted_text
61
-
66
+
62
67
  # Insert placeholder at cursor position
63
68
  chars = (lines[line_index] || "").chars
64
69
  placeholder_chars = placeholder.chars
@@ -91,8 +96,33 @@ module Clacky
91
96
  cursor_pos = 0
92
97
 
93
98
  when "\r" # Enter - submit
99
+ # Check if it's a command
100
+ input_text = lines.join("\n").strip
101
+
102
+ if input_text.start_with?('/')
103
+ clear_simple_prompt(lines.size)
104
+
105
+ # Parse command
106
+ case input_text
107
+ when '/clear'
108
+ @last_display_lines = 0 # Reset so CLI messages won't be cleared
109
+ return { command: :clear }
110
+ when '/exit', '/quit'
111
+ @last_display_lines = 0 # Reset before exit
112
+ return { command: :exit }
113
+ else
114
+ # Unknown command - show error and continue
115
+ @formatter.warning("Unknown command: #{input_text} (Available: /clear, /exit)")
116
+ @last_display_lines = 0 # Reset so next display won't clear these messages
117
+ lines = []
118
+ cursor_pos = 0
119
+ line_index = 0
120
+ next
121
+ end
122
+ end
123
+
94
124
  # Submit if not empty
95
- unless lines.join.strip.empty? && @images.empty?
125
+ unless input_text.empty? && @images.empty?
96
126
  clear_simple_prompt(lines.size)
97
127
  # Replace placeholders with actual pasted content
98
128
  final_text = expand_placeholders(lines.join("\n"))
@@ -102,12 +132,12 @@ module Clacky
102
132
  when "\u0003" # Ctrl+C
103
133
  # Check if input is empty
104
134
  has_content = lines.any? { |line| !line.strip.empty? } || @images.any?
105
-
135
+
106
136
  if has_content
107
137
  # Input has content - clear it on first Ctrl+C
108
138
  current_time = Time.now.to_f
109
139
  time_since_last = @last_ctrl_c_time ? (current_time - @last_ctrl_c_time) : Float::INFINITY
110
-
140
+
111
141
  if time_since_last < 2.0 # Within 2 seconds of last Ctrl+C
112
142
  # Second Ctrl+C within 2 seconds - exit
113
143
  clear_simple_prompt(lines.size)
@@ -131,19 +161,51 @@ module Clacky
131
161
  when "\u0016" # Ctrl+V - Paste
132
162
  pasted = paste_from_clipboard
133
163
  if pasted[:type] == :image
134
- # Save image and add to list
135
- @images << pasted[:path]
164
+ # Save image and add to list (max 3 images)
165
+ if @images.size < 3
166
+ @images << pasted[:path]
167
+ else
168
+ # Show warning below input box (without extra newline)
169
+ @formatter.warning("Maximum 3 images allowed. Delete an image first (Ctrl+D).")
170
+
171
+ # Wait a moment for user to see the message
172
+ sleep(1.5)
173
+
174
+ # Clear the warning line
175
+ print "\r\e[2K" # Clear current line
176
+
177
+ # Now clear the entire input box using the saved line count
178
+ if @last_display_lines && @last_display_lines > 0
179
+ # Move up to the first line of input box
180
+ (@last_display_lines - 1).times do
181
+ print "\e[1A"
182
+ end
183
+ # Clear all lines
184
+ @last_display_lines.times do |i|
185
+ print "\r\e[2K"
186
+ print "\e[1B" if i < @last_display_lines - 1
187
+ end
188
+ # Move back to the first line
189
+ (@last_display_lines - 1).times do
190
+ print "\e[1A"
191
+ end
192
+ print "\r"
193
+ end
194
+
195
+ # Reset display state so next display will redraw
196
+ @last_display_lines = 0
197
+ end
136
198
  else
137
199
  # Handle pasted text
138
200
  pasted_text = pasted[:text]
139
- pasted_lines = pasted_text.split("\n")
140
-
201
+ pasted_lines = pasted_text.split(/\r\n|\r|\n/)
202
+
141
203
  if pasted_lines.size > 1
142
204
  # Multi-line paste - use placeholder for display
143
205
  @paste_counter += 1
144
206
  placeholder = "[##{@paste_counter} Paste Text]"
145
207
  @paste_placeholders[placeholder] = pasted_text
146
-
208
+
147
209
  # Insert placeholder at cursor position
148
210
  chars = (lines[line_index] || "").chars
149
211
  placeholder_chars = placeholder.chars
@@ -196,12 +258,140 @@ module Clacky
196
258
  when "\e[D" # Left arrow
197
259
  cursor_pos = [cursor_pos - 1, 0].max
198
260
 
261
+ # Ignore Shift+Arrow keys (they produce sequences like \e[1;2A, \e[1;2B, etc.)
262
+ when /\e\[1;2[ABCD]/
263
+ # Do nothing - ignore Shift+Arrow keys
264
+
265
+ when "\e[Z" # Shift+Tab - Toggle auto-approve mode
266
+ # Call the block to update status bar if provided
267
+ if block
268
+ block.call(@last_display_lines)
269
+ end
270
+ # Continue the input loop, don't return
271
+
272
+ when "\u0001" # Ctrl+A - Move to beginning of line
273
+ cursor_pos = 0
274
+
275
+ when "\u0005" # Ctrl+E - Move to end of line
276
+ current_line = lines[line_index] || ""
277
+ cursor_pos = current_line.chars.length
278
+
279
+ when "\u0006" # Ctrl+F - Move forward one character
280
+ current_line = lines[line_index] || ""
281
+ cursor_pos = [cursor_pos + 1, current_line.chars.length].min
282
+
283
+ when "\u0002" # Ctrl+B - Move backward one character
284
+ cursor_pos = [cursor_pos - 1, 0].max
285
+
286
+ when "\u000B" # Ctrl+K - Delete from cursor to end of line
287
+ current_line = lines[line_index] || ""
288
+ chars = current_line.chars
289
+ lines[line_index] = chars[0...cursor_pos].join
290
+
291
+ when "\u0015" # Ctrl+U - Delete from beginning of line to cursor
292
+ current_line = lines[line_index] || ""
293
+ chars = current_line.chars
294
+ lines[line_index] = chars[cursor_pos..-1].join || ""
295
+ cursor_pos = 0
296
+
297
+ when "\u0017" # Ctrl+W - Delete previous word
298
+ current_line = lines[line_index] || ""
299
+ chars = current_line.chars
300
+
301
+ # Find the start of the previous word
302
+ pos = cursor_pos - 1
303
+
304
+ # Skip trailing whitespace
305
+ while pos >= 0 && chars[pos] =~ /\s/
306
+ pos -= 1
307
+ end
308
+
309
+ # Delete word characters
310
+ while pos >= 0 && chars[pos] =~ /\S/
311
+ pos -= 1
312
+ end
313
+
314
+ # Delete from pos+1 to cursor_pos
315
+ delete_start = pos + 1
316
+ chars.slice!(delete_start...cursor_pos)
317
+ lines[line_index] = chars.join
318
+ cursor_pos = delete_start
319
+
199
320
  when "\u0004" # Ctrl+D - Delete image by number
200
321
  if @images.any?
201
- print "\nEnter image number to delete (1-#{@images.size}): "
202
- num = STDIN.gets.to_i
203
- if num > 0 && num <= @images.size
204
- @images.delete_at(num - 1)
322
+ # If only one image, delete it directly
323
+ if @images.size == 1
324
+ @images.clear
325
+
326
+ # Clear the entire input box
327
+ if @last_display_lines && @last_display_lines > 0
328
+ # Move up to the first line of input box
329
+ (@last_display_lines - 1).times do
330
+ print "\e[1A"
331
+ end
332
+ # Clear all lines
333
+ @last_display_lines.times do |i|
334
+ print "\r\e[2K"
335
+ print "\e[1B" if i < @last_display_lines - 1
336
+ end
337
+ # Move back to the first line
338
+ (@last_display_lines - 1).times do
339
+ print "\e[1A"
340
+ end
341
+ print "\r"
342
+ end
343
+
344
+ # Reset so next display starts fresh
345
+ @last_display_lines = 0
346
+ else
347
+ # Multiple images - ask which one to delete
348
+ # Move cursor to after the input box to show prompt
349
+ print "\n"
350
+ print "Delete image (1-#{@images.size}): "
351
+ $stdout.flush
352
+
353
+ # Read single character without waiting for Enter
354
+ deleted = false
355
+ $stdin.raw do |io|
356
+ char = io.getc
357
+ num = char.to_i
358
+
359
+ # Delete if valid number
360
+ if num > 0 && num <= @images.size
361
+ @images.delete_at(num - 1)
362
+ print "#{num} ✓"
363
+ deleted = true
364
+ else
365
+ print "✗"
366
+ end
367
+ end
368
+
369
+ # Clear the prompt lines
370
+ print "\r\e[2K" # Clear current line
371
+ print "\e[1A" # Move up one line
372
+ print "\r\e[2K" # Clear the prompt line
373
+
374
+ # Now clear the entire input box using the saved line count
375
+ if @last_display_lines && @last_display_lines > 0
376
+ # We're now at the position where the input box ends
377
+ # Move up to the first line of input box
378
+ (@last_display_lines - 1).times do
379
+ print "\e[1A"
380
+ end
381
+ # Clear all lines
382
+ @last_display_lines.times do |i|
383
+ print "\r\e[2K"
384
+ print "\e[1B" if i < @last_display_lines - 1
385
+ end
386
+ # Move back to the first line
387
+ (@last_display_lines - 1).times do
388
+ print "\e[1A"
389
+ end
390
+ print "\r"
391
+ end
392
+
393
+ # Reset so next display starts fresh
394
+ @last_display_lines = 0
205
395
  end
206
396
  end
207
397
 
@@ -210,7 +400,7 @@ module Clacky
210
400
  if key.length >= 1 && key != "\e" && !key.start_with?("\e") && key.ord >= 32
211
401
  lines[line_index] ||= ""
212
402
  current_line = lines[line_index]
213
-
403
+
214
404
  # Insert character at cursor position (using character index, not byte index)
215
405
  chars = current_line.chars
216
406
  chars.insert(cursor_pos, key)
@@ -228,17 +418,11 @@ module Clacky
228
418
 
229
419
  # Display simplified prompt (just prefix and input, no box)
230
420
  def display_simple_prompt(lines, prefix, line_index, cursor_pos)
231
- # Clear previous display if exists
232
- if @last_display_lines && @last_display_lines > 0
233
- @last_display_lines.times do
234
- print "\e[1A" # Move up one line
235
- print "\e[2K" # Clear entire line
236
- end
237
- print "\r" # Move to beginning of line
238
- end
421
+ # Hide terminal cursor (we render our own)
422
+ print "\e[?25l"
239
423
 
240
424
  lines_to_display = []
241
-
425
+
242
426
  # Get terminal width for full-width separator
243
427
  term_width = TTY::Screen.width
244
428
 
@@ -250,14 +434,14 @@ module Clacky
250
434
  @images.each_with_index do |img_path, idx|
251
435
  filename = File.basename(img_path)
252
436
  filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
253
- lines_to_display << @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize})")
437
+ line = @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize}) (Ctrl+D to delete)")
438
+ lines_to_display << line
254
439
  end
255
- lines_to_display << ""
256
440
  end
257
441
 
258
442
  # Display input lines
259
443
  display_lines = lines.empty? ? [""] : lines
260
-
444
+
261
445
  display_lines.each_with_index do |line, idx|
262
446
  if idx == 0
263
447
  # First line with prefix
@@ -267,7 +451,7 @@ module Clacky
267
451
  before_cursor = chars[0...cursor_pos].join
268
452
  cursor_char = chars[cursor_pos] || " "
269
453
  after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
270
-
454
+
271
455
  line_display = "#{prefix} #{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
272
456
  lines_to_display << line_display
273
457
  else
@@ -282,7 +466,7 @@ module Clacky
282
466
  before_cursor = chars[0...cursor_pos].join
283
467
  cursor_char = chars[cursor_pos] || " "
284
468
  after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
285
-
469
+
286
470
  line_display = "#{indent}#{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
287
471
  lines_to_display << line_display
288
472
  else
@@ -290,27 +474,72 @@ module Clacky
290
474
  end
291
475
  end
292
476
  end
293
-
477
+
294
478
  # Bottom separator line (full width)
295
479
  lines_to_display << @pastel.dim("─" * term_width)
296
480
 
297
- # Output all lines
298
- print lines_to_display.join("\n")
299
- print "\n"
300
-
301
- # Remember how many lines we displayed
302
- @last_display_lines = lines_to_display.size
481
+ # Different rendering strategy for first display vs updates
482
+ if @last_display_lines && @last_display_lines > 0
483
+ # Update mode: move to start and overwrite (no flicker)
484
+ # Move up to the first line (N-1 times since we're on line N)
485
+ (@last_display_lines - 1).times do
486
+ print "\e[1A" # Move up one line
487
+ end
488
+ print "\r" # Move to beginning of line
489
+
490
+ # Output lines by overwriting
491
+ lines_to_display.each_with_index do |line, idx|
492
+ print "\r\e[K" # Clear current line from cursor to end
493
+ print line
494
+ print "\n" if idx < lines_to_display.size - 1 # Newline except last line
495
+ end
496
+
497
+ # If new display has fewer lines than old, clear the extra lines
498
+ if lines_to_display.size < @last_display_lines - 1
499
+ extra_lines = @last_display_lines - 1 - lines_to_display.size
500
+ extra_lines.times do
501
+ print "\n\r\e[K" # Move down and clear line
502
+ end
503
+ # Move back up to the last line of new display
504
+ extra_lines.times do
505
+ print "\e[1A"
506
+ end
507
+ end
508
+
509
+ print "\n" # Move cursor to next line
510
+ else
511
+ # First display: use simple newline approach
512
+ print lines_to_display.join("\n")
513
+ print "\n"
514
+ end
515
+
516
+ # Flush output to ensure it's displayed immediately
517
+ $stdout.flush
518
+
519
+ # Remember how many lines we displayed (including the newline)
520
+ @last_display_lines = lines_to_display.size + 1
303
521
  end
304
522
 
305
523
  # Clear simple prompt display
306
524
  def clear_simple_prompt(num_lines)
307
525
  if @last_display_lines && @last_display_lines > 0
308
- @last_display_lines.times do
526
+ # Move up to the first line (N-1 times since we're on line N)
527
+ (@last_display_lines - 1).times do
309
528
  print "\e[1A" # Move up one line
310
- print "\e[2K" # Clear entire line
529
+ end
530
+ # Now we're on the first line, clear all N lines
531
+ @last_display_lines.times do |i|
532
+ print "\r\e[2K" # Move to beginning and clear entire line
533
+ print "\e[1B" if i < @last_display_lines - 1 # Move down (except last line)
534
+ end
535
+ # Move back to the first line
536
+ (@last_display_lines - 1).times do
537
+ print "\e[1A"
311
538
  end
312
539
  print "\r" # Move to beginning of line
313
540
  end
541
+ # Show terminal cursor again
542
+ print "\e[?25h"
314
543
  end
315
544
 
316
545
  # Expand placeholders to actual pasted content
@@ -327,74 +556,91 @@ module Clacky
327
556
  # Also detects rapid input (paste-like behavior)
328
557
  def read_key_with_rapid_detection
329
558
  $stdin.set_encoding('UTF-8')
330
-
559
+
331
560
  current_time = Time.now.to_f
332
561
  is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
333
562
  @last_input_time = current_time
334
-
563
+
335
564
  $stdin.raw do |io|
336
565
  io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
337
566
  c = io.getc
338
-
567
+
339
568
  # Ensure character is UTF-8 encoded
340
569
  c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
341
-
570
+
342
571
  # Handle escape sequences (arrow keys, special keys)
343
572
  if c == "\e"
344
- # Read the next 2 characters for escape sequences
573
+ # Read the next character to determine sequence type
345
574
  begin
346
- extra = io.read_nonblock(2)
347
- extra = extra.force_encoding('UTF-8') if extra.encoding != Encoding::UTF_8
348
- c = c + extra
575
+ next_char = io.read_nonblock(1)
576
+ next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
577
+ c = c + next_char
578
+
579
+ # If it's a CSI sequence (starts with [)
580
+ if next_char == "["
581
+ # Read until we get a letter (the final character of CSI sequence)
582
+ # This handles both simple sequences like \e[A and complex ones like \e[1;2D
583
+ loop do
584
+ if IO.select([io], nil, nil, 0.01) # 10ms timeout
585
+ char = io.read_nonblock(1)
586
+ char = char.force_encoding('UTF-8') if char.encoding != Encoding::UTF_8
587
+ c = c + char
588
+ # Break if we got a letter (final character of CSI sequence)
589
+ break if char =~ /[A-Za-z~]/
590
+ else
591
+ break
592
+ end
593
+ end
594
+ end
349
595
  rescue IO::WaitReadable, Errno::EAGAIN
350
596
  # No more characters available
351
597
  end
352
598
  return c
353
599
  end
354
-
600
+
355
601
  # Check if there are more characters available using IO.select with timeout 0
356
602
  has_more_input = IO.select([io], nil, nil, 0)
357
-
603
+
358
604
  # If this is rapid input or there are more characters available
359
605
  if is_rapid_input || has_more_input
360
606
  # Buffer rapid input
361
607
  buffer = c.to_s.dup
362
608
  buffer.force_encoding('UTF-8')
363
-
609
+
364
610
  # Keep reading available characters
365
611
  loop do
366
612
  begin
367
613
  next_char = io.read_nonblock(1)
368
614
  next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
369
615
  buffer << next_char
370
-
616
+
371
617
  # Continue only if more characters are immediately available
372
618
  break unless IO.select([io], nil, nil, 0)
373
619
  rescue IO::WaitReadable, Errno::EAGAIN
374
620
  break
375
621
  end
376
622
  end
377
-
623
+
378
624
  # Ensure buffer is UTF-8
379
625
  buffer.force_encoding('UTF-8')
380
-
626
+
381
627
  # If we buffered multiple characters or newlines, treat as rapid input (paste)
382
628
  if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
383
629
  # Remove any trailing \r or \n from rapid input buffer
384
630
  cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
385
631
  return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
386
632
  end
387
-
633
+
388
634
  # Single character rapid input, return as-is
389
635
  return buffer[0] if buffer.length == 1
390
636
  end
391
-
637
+
392
638
  c
393
639
  end
394
640
  rescue Errno::EINTR
395
641
  "\u0003" # Treat interrupt as Ctrl+C
396
642
  end
397
-
643
+
398
644
  # Legacy method for compatibility
399
645
  def read_key
400
646
  read_key_with_rapid_detection
@@ -419,17 +665,17 @@ module Clacky
419
665
  def paste_from_clipboard_macos
420
666
  require 'shellwords'
421
667
  require 'fileutils'
422
-
668
+
423
669
  # First check if there's an image in clipboard
424
670
  # Use osascript to check clipboard content type
425
671
  has_image = system("osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'on error' -e 'return false' -e 'end try' >/dev/null 2>&1")
426
-
672
+
427
673
  if has_image
428
674
  # Create a persistent temporary file (won't be auto-deleted)
429
675
  temp_dir = Dir.tmpdir
430
676
  temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
431
677
  temp_path = File.join(temp_dir, temp_filename)
432
-
678
+
433
679
  # Extract image using osascript
434
680
  script = <<~APPLESCRIPT
435
681
  set png_data to the clipboard as «class PNGf»
@@ -437,9 +683,9 @@ module Clacky
437
683
  write png_data to the_file
438
684
  close access the_file
439
685
  APPLESCRIPT
440
-
686
+
441
687
  success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
442
-
688
+
443
689
  if success && File.exist?(temp_path) && File.size(temp_path) > 0
444
690
  return { type: :image, path: temp_path }
445
691
  end
@@ -459,13 +705,13 @@ module Clacky
459
705
  # Paste from Linux clipboard
460
706
  def paste_from_clipboard_linux
461
707
  require 'shellwords'
462
-
708
+
463
709
  # Check if xclip is available
464
710
  if system("which xclip >/dev/null 2>&1")
465
711
  # Try to get image first
466
712
  temp_file = Tempfile.new(["clipboard-", ".png"])
467
713
  temp_file.close
468
-
714
+
469
715
  # Try different image MIME types
470
716
  ["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
471
717
  if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
@@ -474,7 +720,7 @@ module Clacky
474
720
  end
475
721
  end
476
722
  end
477
-
723
+
478
724
  # No image, get text - ensure UTF-8 encoding
479
725
  text = `xclip -selection clipboard -o 2>/dev/null`.to_s
480
726
  text.force_encoding('UTF-8')
@@ -498,7 +744,7 @@ module Clacky
498
744
  # Try to get image using PowerShell
499
745
  temp_file = Tempfile.new(["clipboard-", ".png"])
500
746
  temp_file.close
501
-
747
+
502
748
  ps_script = <<~POWERSHELL
503
749
  Add-Type -AssemblyName System.Windows.Forms
504
750
  $img = [Windows.Forms.Clipboard]::GetImage()
@@ -509,9 +755,9 @@ module Clacky
509
755
  exit 1
510
756
  }
511
757
  POWERSHELL
512
-
758
+
513
759
  success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
514
-
760
+
515
761
  if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
516
762
  return { type: :image, path: temp_file.path }
517
763
  end
@@ -32,7 +32,7 @@ module Clacky
32
32
  def user_message(content)
33
33
  symbol = @pastel.bright_blue(SYMBOLS[:user])
34
34
  text = @pastel.blue(content)
35
- puts "\n#{symbol} #{text}"
35
+ print "\n#{symbol} #{text}"
36
36
  end
37
37
 
38
38
  # Format assistant message
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.5.5"
4
+ VERSION = "0.5.6"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.5
4
+ version: 0.5.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy