openclacky 0.6.1 → 0.6.3

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.
@@ -2,7 +2,6 @@
2
2
 
3
3
  require_relative "layout_manager"
4
4
  require_relative "view_renderer"
5
- require_relative "components/output_area"
6
5
  require_relative "components/input_area"
7
6
  require_relative "components/todo_area"
8
7
  require_relative "components/welcome_banner"
@@ -31,13 +30,11 @@ module Clacky
31
30
  }
32
31
 
33
32
  # Initialize layout components
34
- @output_area = Components::OutputArea.new(height: 20) # Will be recalculated
35
33
  @input_area = Components::InputArea.new
36
34
  @todo_area = Components::TodoArea.new
37
35
  @welcome_banner = Components::WelcomeBanner.new
38
36
  @inline_input = nil # Created when needed
39
37
  @layout = LayoutManager.new(
40
- output_area: @output_area,
41
38
  input_area: @input_area,
42
39
  todo_area: @todo_area
43
40
  )
@@ -150,6 +147,12 @@ module Clacky
150
147
  @mode_toggle_callback = block
151
148
  end
152
149
 
150
+ # Set skill loader for command suggestions
151
+ # @param skill_loader [Clacky::SkillLoader] The skill loader instance
152
+ def set_skill_loader(skill_loader)
153
+ @input_area.set_skill_loader(skill_loader)
154
+ end
155
+
153
156
  # Append output to the output area
154
157
  # @param content [String] Content to append
155
158
  def append_output(content)
@@ -211,18 +214,24 @@ module Clacky
211
214
  # - cost: cost for this iteration
212
215
  def show_token_usage(token_data)
213
216
  theme = ThemeManager.current_theme
217
+ pastel = Pastel.new
214
218
 
215
219
  token_info = []
216
220
 
217
- # Delta tokens with color coding
221
+ # Delta tokens with color coding (green/yellow/red + dim)
218
222
  delta_tokens = token_data[:delta_tokens]
219
- delta_str = "+#{delta_tokens}"
220
- colored_delta = if delta_tokens > 10000
221
- theme.format_text(delta_str, :error)
223
+ delta_str = delta_tokens.negative? ? "#{delta_tokens}" : "+#{delta_tokens}"
224
+ color_style = if delta_tokens > 10000
225
+ :red
222
226
  elsif delta_tokens > 5000
223
- theme.format_text(delta_str, :warning)
227
+ :yellow
228
+ else
229
+ :green
230
+ end
231
+ colored_delta = if delta_tokens.negative?
232
+ pastel.cyan(delta_str)
224
233
  else
225
- theme.format_text(delta_str, :success)
234
+ pastel.decorate(delta_str, color_style, :dim)
226
235
  end
227
236
  token_info << colored_delta
228
237
 
@@ -231,31 +240,44 @@ module Clacky
231
240
  cache_read = token_data[:cache_read]
232
241
  cache_used = cache_read > 0 || cache_write > 0
233
242
  if cache_used
234
- token_info << theme.format_symbol(:cached)
243
+ token_info << pastel.dim(theme.symbol(:cached))
235
244
  end
236
245
 
237
246
  # Input tokens (with cache breakdown if available)
238
247
  prompt_tokens = token_data[:prompt_tokens]
239
248
  if cache_write > 0 || cache_read > 0
240
249
  input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
241
- token_info << "Input: #{input_detail}"
250
+ token_info << pastel.dim("Input: #{input_detail}")
242
251
  else
243
- token_info << "Input: #{prompt_tokens}"
252
+ token_info << pastel.dim("Input: #{prompt_tokens}")
244
253
  end
245
254
 
246
255
  # Output tokens
247
- token_info << "Output: #{token_data[:completion_tokens]}"
256
+ token_info << pastel.dim("Output: #{token_data[:completion_tokens]}")
248
257
 
249
258
  # Total
250
- token_info << "Total: #{token_data[:total_tokens]}"
259
+ token_info << pastel.dim("Total: #{token_data[:total_tokens]}")
251
260
 
252
- # Cost for this iteration
261
+ # Cost for this iteration with color coding (red/yellow for high cost, dim for normal)
253
262
  if token_data[:cost]
254
- token_info << "Cost: $#{token_data[:cost].round(6)}"
263
+ cost = token_data[:cost]
264
+ cost_value = "$#{cost.round(6)}"
265
+ if cost >= 0.1
266
+ # High cost - red warning
267
+ colored_cost = pastel.decorate(cost_value, :red, :dim)
268
+ token_info << pastel.dim("Cost: ") + colored_cost
269
+ elsif cost >= 0.05
270
+ # Medium cost - yellow warning
271
+ colored_cost = pastel.decorate(cost_value, :yellow, :dim)
272
+ token_info << pastel.dim("Cost: ") + colored_cost
273
+ else
274
+ # Low cost - normal gray
275
+ token_info << pastel.dim("Cost: #{cost_value}")
276
+ end
255
277
  end
256
278
 
257
- # Display through output system
258
- token_display = theme.format_text(" [Tokens] #{token_info.join(' | ')}", :thinking)
279
+ # Display through output system (already all dimmed, just add prefix)
280
+ token_display = pastel.dim(" [Tokens] ") + token_info.join(pastel.dim(' | '))
259
281
  append_output(token_display)
260
282
  end
261
283
 
@@ -313,10 +335,32 @@ module Clacky
313
335
  # Show assistant message
314
336
  # @param content [String] Message content
315
337
  def show_assistant_message(content)
316
- output = @renderer.render_assistant_message(content)
338
+ # Filter out thinking tags from models like MiniMax M2.1 that use <think>...</think>
339
+ filtered_content = filter_thinking_tags(content)
340
+ return if filtered_content.nil? || filtered_content.strip.empty?
341
+
342
+ output = @renderer.render_assistant_message(filtered_content)
317
343
  append_output(output)
318
344
  end
319
345
 
346
+ # Filter out thinking tags from content
347
+ # Some models (e.g., MiniMax M2.1) wrap their reasoning in <think>...</think> tags
348
+ # @param content [String] Raw content from model
349
+ # @return [String] Content with thinking tags removed
350
+ def filter_thinking_tags(content)
351
+ return content if content.nil?
352
+
353
+ # Remove <think>...</think> blocks (multiline, case-insensitive)
354
+ # Also handles variations like <thinking>...</thinking>
355
+ filtered = content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, '')
356
+
357
+ # Clean up multiple empty lines left behind (max 2 consecutive newlines)
358
+ filtered.gsub!(/\n{3,}/, "\n\n")
359
+
360
+ # Remove leading and trailing whitespace
361
+ filtered.strip
362
+ end
363
+
320
364
  # Show tool call
321
365
  # @param name [String] Tool name
322
366
  # @param args [String, Hash] Tool arguments (JSON string or Hash)
@@ -525,8 +569,9 @@ module Clacky
525
569
  # Collect input (blocks until user presses Enter)
526
570
  result_text = inline_input.collect
527
571
 
528
- # Clean up - remove the inline input line (use layout to track position)
529
- @layout.remove_last_line
572
+ # Clean up - remove the inline input lines (handle wrapped lines)
573
+ line_count = inline_input.line_count
574
+ @layout.remove_last_line(line_count)
530
575
 
531
576
  # Append the final response to output
532
577
  if result_text.nil?
@@ -563,12 +608,15 @@ module Clacky
563
608
  require 'diffy'
564
609
 
565
610
  diff = Diffy::Diff.new(old_content, new_content, context: 3)
566
- all_lines = diff.to_s(:color).lines
567
- display_lines = all_lines.first(max_lines)
611
+ diff_lines = diff.to_s(:color).lines
612
+
613
+ # Show diff without line numbers
614
+ diff_lines.take(max_lines).each do |line|
615
+ append_output(line.chomp)
616
+ end
568
617
 
569
- display_lines.each { |line| append_output(line.chomp) }
570
- if all_lines.size > max_lines
571
- append_output("\n... (#{all_lines.size - max_lines} more lines, diff truncated)")
618
+ if diff_lines.size > max_lines
619
+ append_output("\n... (#{diff_lines.size - max_lines} more lines, diff truncated)")
572
620
  end
573
621
  rescue LoadError
574
622
  # Fallback if diffy is not available
@@ -728,16 +776,19 @@ module Clacky
728
776
 
729
777
  # Handle key input for InlineInput
730
778
  def handle_inline_input_key(key)
779
+ # Get old line count BEFORE modification
780
+ old_line_count = @inline_input.line_count
781
+
731
782
  result = @inline_input.handle_key(key)
732
783
 
733
784
  case result[:action]
734
785
  when :update
735
- # Update the last line of output with current input (use layout to track position)
736
- @layout.update_last_line(@inline_input.render)
786
+ # Update the output area with current input (considering wrapped lines)
787
+ @layout.update_last_line(@inline_input.render, old_line_count)
737
788
  # Position cursor for inline input
738
789
  @layout.position_inline_input_cursor(@inline_input)
739
790
  when :submit, :cancel
740
- # InlineInput is done, will be cleaned up by request_confirmation
791
+ # InlineInput is done, will be cleaned up by request_confirmation after collect returns
741
792
  nil
742
793
  when :toggle_mode
743
794
  # Update mode and session bar info, but don't render yet
data/lib/clacky/ui2.rb CHANGED
@@ -10,7 +10,6 @@ require_relative "ui2/view_renderer"
10
10
  require_relative "ui2/ui_controller"
11
11
 
12
12
  require_relative "ui2/components/base_component"
13
- require_relative "ui2/components/output_area"
14
13
  require_relative "ui2/components/input_area"
15
14
  require_relative "ui2/components/message_component"
16
15
  require_relative "ui2/components/tool_component"
@@ -44,10 +44,11 @@ module Clacky
44
44
  result
45
45
  end
46
46
 
47
- # Validate required parameters
47
+ # Validate required parameters and filter unknown parameters
48
48
  def self.validate_required_params(call, args, tool_registry)
49
49
  tool = tool_registry.get(call[:name])
50
50
  required = tool.parameters&.dig(:required) || []
51
+ properties = tool.parameters&.dig(:properties) || {}
51
52
 
52
53
  missing = required.reject { |param|
53
54
  args.key?(param.to_sym) || args.key?(param.to_s)
@@ -57,7 +58,11 @@ module Clacky
57
58
  raise MissingRequiredParamsError.new(call[:name], missing, args.keys)
58
59
  end
59
60
 
60
- args
61
+ # Filter out unknown parameters to prevent errors when LLM sends extra arguments
62
+ known_params = properties.keys.map(&:to_sym) + properties.keys.map(&:to_s)
63
+ filtered_args = args.select { |key, _| known_params.include?(key) }
64
+
65
+ filtered_args
61
66
  end
62
67
 
63
68
  # Generate error message with tool definition
@@ -30,20 +30,18 @@ module Clacky
30
30
  /\.ini$/,
31
31
  /\.conf$/,
32
32
  /\.config$/,
33
- /config\//,
34
- /\.config\//
35
33
  ].freeze
36
34
 
37
35
  # Find .gitignore file in the search path or parent directories
38
36
  # Only searches within the search path and up to the current working directory
39
37
  def self.find_gitignore(path)
40
38
  search_path = File.directory?(path) ? path : File.dirname(path)
41
-
39
+
42
40
  # Look for .gitignore in current and parent directories
43
41
  current = File.expand_path(search_path)
44
42
  cwd = File.expand_path(Dir.pwd)
45
43
  root = File.expand_path('/')
46
-
44
+
47
45
  # Limit search: only go up to current working directory
48
46
  # This prevents finding .gitignore files from unrelated parent directories
49
47
  # when searching in temporary directories (like /tmp in tests)
@@ -52,16 +50,16 @@ module Clacky
52
50
  else
53
51
  current
54
52
  end
55
-
53
+
56
54
  loop do
57
55
  gitignore = File.join(current, '.gitignore')
58
56
  return gitignore if File.exist?(gitignore)
59
-
57
+
60
58
  # Stop if we've reached the search limit or root
61
59
  break if current == search_limit || current == root
62
60
  current = File.dirname(current)
63
61
  end
64
-
62
+
65
63
  nil
66
64
  end
67
65
 
@@ -71,10 +69,10 @@ module Clacky
71
69
  # Expand both paths to handle symlinks and relative paths correctly
72
70
  expanded_file = File.expand_path(file)
73
71
  expanded_base = File.expand_path(base_path)
74
-
72
+
75
73
  # For files, use the directory as base
76
74
  expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
77
-
75
+
78
76
  # Calculate relative path
79
77
  if expanded_file.start_with?(expanded_base)
80
78
  relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
@@ -82,10 +80,10 @@ module Clacky
82
80
  # File is outside base path - use just the filename
83
81
  relative_path = File.basename(expanded_file)
84
82
  end
85
-
83
+
86
84
  # Clean up relative path
87
85
  relative_path = relative_path.sub(/^\.\//, '') if relative_path
88
-
86
+
89
87
  if gitignore
90
88
  # Use .gitignore rules
91
89
  gitignore.ignored?(relative_path)
@@ -96,7 +94,7 @@ module Clacky
96
94
  File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
97
95
  else
98
96
  # Match pattern as a path component (not substring of absolute path)
99
- relative_path.start_with?("#{pattern}/") ||
97
+ relative_path.start_with?("#{pattern}/") ||
100
98
  relative_path.include?("/#{pattern}/") ||
101
99
  relative_path == pattern ||
102
100
  File.basename(relative_path) == pattern
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Clacky
6
+ module Utils
7
+ # File processing utilities for binary files, images, and PDFs
8
+ class FileProcessor
9
+ # Maximum file size for binary files (5MB)
10
+ MAX_FILE_SIZE = 5 * 1024 * 1024
11
+
12
+ # Supported image formats
13
+ IMAGE_FORMATS = {
14
+ "png" => "image/png",
15
+ "jpg" => "image/jpeg",
16
+ "jpeg" => "image/jpeg",
17
+ "gif" => "image/gif",
18
+ "webp" => "image/webp"
19
+ }.freeze
20
+
21
+ # Supported document formats
22
+ DOCUMENT_FORMATS = {
23
+ "pdf" => "application/pdf"
24
+ }.freeze
25
+
26
+ # All supported formats
27
+ SUPPORTED_FORMATS = IMAGE_FORMATS.merge(DOCUMENT_FORMATS).freeze
28
+
29
+ # File signatures (magic bytes) for format detection
30
+ FILE_SIGNATURES = {
31
+ "\x89PNG\r\n\x1a\n".b => "png",
32
+ "\xFF\xD8\xFF".b => "jpg",
33
+ "GIF87a".b => "gif",
34
+ "GIF89a".b => "gif",
35
+ "%PDF".b => "pdf"
36
+ }.freeze
37
+
38
+ class << self
39
+ # Convert image file path to base64 data URL
40
+ # @param path [String] File path to image
41
+ # @return [String] base64 data URL (e.g., "data:image/png;base64,...")
42
+ # @raise [ArgumentError] If file not found or unsupported format
43
+ def image_path_to_data_url(path)
44
+ unless File.exist?(path)
45
+ raise ArgumentError, "Image file not found: #{path}"
46
+ end
47
+
48
+ # Check file size
49
+ file_size = File.size(path)
50
+ if file_size > MAX_FILE_SIZE
51
+ raise ArgumentError, "File too large: #{file_size} bytes (max: #{MAX_FILE_SIZE} bytes)"
52
+ end
53
+
54
+ # Read file as binary
55
+ image_data = File.binread(path)
56
+
57
+ # Detect MIME type from file extension or content
58
+ mime_type = detect_mime_type(path, image_data)
59
+
60
+ # Verify it's an image format
61
+ unless IMAGE_FORMATS.values.include?(mime_type)
62
+ raise ArgumentError, "Unsupported image format: #{mime_type}"
63
+ end
64
+
65
+ # Encode to base64
66
+ base64_data = Base64.strict_encode64(image_data)
67
+
68
+ "data:#{mime_type};base64,#{base64_data}"
69
+ end
70
+
71
+ # Convert file to base64 with format detection
72
+ # @param path [String] File path
73
+ # @return [Hash] Hash with :format, :mime_type, :base64_data, :size_bytes
74
+ # @raise [ArgumentError] If file not found or too large
75
+ def file_to_base64(path)
76
+ unless File.exist?(path)
77
+ raise ArgumentError, "File not found: #{path}"
78
+ end
79
+
80
+ # Check file size
81
+ file_size = File.size(path)
82
+ if file_size > MAX_FILE_SIZE
83
+ raise ArgumentError, "File too large: #{file_size} bytes (max: #{MAX_FILE_SIZE} bytes)"
84
+ end
85
+
86
+ # Read file as binary
87
+ file_data = File.binread(path)
88
+
89
+ # Detect format and MIME type
90
+ format = detect_format(path, file_data)
91
+ mime_type = detect_mime_type(path, file_data)
92
+
93
+ # Encode to base64
94
+ base64_data = Base64.strict_encode64(file_data)
95
+
96
+ {
97
+ format: format,
98
+ mime_type: mime_type,
99
+ base64_data: base64_data,
100
+ size_bytes: file_size
101
+ }
102
+ end
103
+
104
+ # Detect file format from path and content
105
+ # @param path [String] File path
106
+ # @param data [String] Binary file data
107
+ # @return [String] Format (e.g., "png", "jpg", "pdf")
108
+ def detect_format(path, data)
109
+ # Try to detect from file extension first
110
+ ext = File.extname(path).downcase.delete_prefix(".")
111
+ return ext if SUPPORTED_FORMATS.key?(ext)
112
+
113
+ # Try to detect from file signature (magic bytes)
114
+ FILE_SIGNATURES.each do |signature, format|
115
+ return format if data.start_with?(signature)
116
+ end
117
+
118
+ # Special case for WebP (RIFF format)
119
+ if data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
120
+ return "webp"
121
+ end
122
+
123
+ nil
124
+ end
125
+
126
+ # Detect MIME type from file path and content
127
+ # @param path [String] File path
128
+ # @param data [String] Binary file data
129
+ # @return [String] MIME type (e.g., "image/png")
130
+ def detect_mime_type(path, data)
131
+ format = detect_format(path, data)
132
+ return SUPPORTED_FORMATS[format] if format && SUPPORTED_FORMATS[format]
133
+
134
+ # Default to application/octet-stream for unknown formats
135
+ "application/octet-stream"
136
+ end
137
+
138
+ # Check if file is a supported binary format
139
+ # @param path [String] File path
140
+ # @return [Boolean] True if supported binary format
141
+ def supported_binary_file?(path)
142
+ return false unless File.exist?(path)
143
+
144
+ ext = File.extname(path).downcase.delete_prefix(".")
145
+ SUPPORTED_FORMATS.key?(ext)
146
+ end
147
+
148
+ # Check if file is an image
149
+ # @param path [String] File path
150
+ # @return [Boolean] True if image format
151
+ def image_file?(path)
152
+ return false unless File.exist?(path)
153
+
154
+ ext = File.extname(path).downcase.delete_prefix(".")
155
+ IMAGE_FORMATS.key?(ext)
156
+ end
157
+
158
+ # Check if file is a PDF
159
+ # @param path [String] File path
160
+ # @return [Boolean] True if PDF format
161
+ def pdf_file?(path)
162
+ return false unless File.exist?(path)
163
+
164
+ ext = File.extname(path).downcase.delete_prefix(".")
165
+ ext == "pdf"
166
+ end
167
+
168
+ # Check if file is binary (not text)
169
+ # @param data [String] File content
170
+ # @param sample_size [Integer] Number of bytes to check (default: 8192)
171
+ # @return [Boolean] True if file appears to be binary
172
+ def binary_file?(data, sample_size: 8192)
173
+ # Check first N bytes for null bytes or high ratio of non-printable characters
174
+ sample = data[0, sample_size] || ""
175
+ return false if sample.empty?
176
+
177
+ # Check for known binary signatures first
178
+ FILE_SIGNATURES.each do |signature, _format|
179
+ return true if sample.start_with?(signature)
180
+ end
181
+
182
+ # Check for WebP (RIFF format)
183
+ if sample.start_with?("RIFF".b) && sample.length >= 12 && sample[8..11] == "WEBP".b
184
+ return true
185
+ end
186
+
187
+ # If contains null bytes, it's binary
188
+ return true if sample.include?("\x00")
189
+
190
+ # Count non-printable characters (excluding common whitespace)
191
+ non_printable = sample.bytes.count do |byte|
192
+ byte < 32 && ![9, 10, 13].include?(byte) || byte >= 127
193
+ end
194
+
195
+ # If more than 30% non-printable, consider it binary
196
+ (non_printable.to_f / sample.size) > 0.3
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.6.1"
4
+ VERSION = "0.6.3"
5
5
  end
data/lib/clacky.rb CHANGED
@@ -3,6 +3,8 @@
3
3
  require_relative "clacky/version"
4
4
  require_relative "clacky/config"
5
5
  require_relative "clacky/client"
6
+ require_relative "clacky/skill"
7
+ require_relative "clacky/skill_loader"
6
8
 
7
9
  # Agent system
8
10
  require_relative "clacky/model_pricing"