openclacky 0.6.1 → 0.6.2

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "base"
4
+ require_relative "../utils/file_processor"
4
5
 
5
6
  module Clacky
6
7
  module Tools
@@ -23,6 +24,8 @@ module Clacky
23
24
  },
24
25
  required: ["path"]
25
26
  }
27
+
28
+
26
29
 
27
30
  def execute(path:, max_lines: 1000)
28
31
  # Expand ~ to home directory
@@ -50,6 +53,12 @@ module Clacky
50
53
  end
51
54
 
52
55
  begin
56
+ # Check if file is binary
57
+ if binary_file?(expanded_path)
58
+ return handle_binary_file(expanded_path)
59
+ end
60
+
61
+ # Read text file
53
62
  lines = File.readlines(expanded_path).first(max_lines)
54
63
  content = lines.join
55
64
  truncated = File.readlines(expanded_path).size > max_lines
@@ -86,16 +95,140 @@ module Clacky
86
95
  return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
87
96
  end
88
97
 
89
- # Handle file reading
98
+ # Handle binary file
99
+ if result[:binary] || result['binary']
100
+ format_type = result[:format] || result['format'] || 'unknown'
101
+ size = result[:size_bytes] || result['size_bytes'] || 0
102
+
103
+ # Check if it has base64 data (LLM-compatible format)
104
+ if result[:base64_data] || result['base64_data']
105
+ size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
106
+ return "Binary file (#{format_type}, #{format_file_size(size)}) - sent to LLM#{size_warning}"
107
+ else
108
+ return "Binary file (#{format_type}, #{format_file_size(size)}) - cannot be read as text"
109
+ end
110
+ end
111
+
112
+ # Handle text file reading
90
113
  lines = result[:lines_read] || result['lines_read'] || 0
91
114
  truncated = result[:truncated] || result['truncated']
92
115
  "Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
93
116
  end
117
+
118
+ # Format result for LLM - handles both text and binary (image/PDF) content
119
+ # This method is called by the agent to format tool results before sending to LLM
120
+ def format_result_for_llm(result)
121
+ # For LLM-compatible binary files with base64 data, return as content blocks
122
+ if result[:binary] && result[:base64_data]
123
+ # Create a text description
124
+ description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
125
+
126
+ # Add size warning for large files
127
+ if result[:size_bytes] > 5_000_000
128
+ description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
129
+ end
130
+
131
+ # For images, return both description and image content
132
+ if result[:mime_type]&.start_with?("image/")
133
+ return {
134
+ type: "image",
135
+ path: result[:path],
136
+ format: result[:format],
137
+ size_bytes: result[:size_bytes],
138
+ mime_type: result[:mime_type],
139
+ base64_data: result[:base64_data],
140
+ description: description
141
+ }
142
+ end
143
+
144
+ # For PDFs and other binary formats, just return metadata with base64
145
+ return {
146
+ type: "document",
147
+ path: result[:path],
148
+ format: result[:format],
149
+ size_bytes: result[:size_bytes],
150
+ mime_type: result[:mime_type],
151
+ base64_data: result[:base64_data],
152
+ description: description
153
+ }
154
+ end
155
+
156
+ # For other cases, return the result as-is (agent will JSON.generate it)
157
+ result
158
+ end
159
+
160
+ private def binary_file?(path)
161
+ # Use FileProcessor to detect binary files
162
+ File.open(path, 'rb') do |file|
163
+ sample = file.read(8192) || ""
164
+ Utils::FileProcessor.binary_file?(sample)
165
+ end
166
+ rescue StandardError
167
+ # If we can't read the file, assume it's not binary
168
+ false
169
+ end
170
+
171
+ private def handle_binary_file(path)
172
+ # Check if it's a supported format using FileProcessor
173
+ if Utils::FileProcessor.supported_binary_file?(path)
174
+ # Use FileProcessor to convert to base64
175
+ begin
176
+ result = Utils::FileProcessor.file_to_base64(path)
177
+ {
178
+ path: path,
179
+ binary: true,
180
+ format: result[:format],
181
+ mime_type: result[:mime_type],
182
+ size_bytes: result[:size_bytes],
183
+ base64_data: result[:base64_data],
184
+ error: nil
185
+ }
186
+ rescue ArgumentError => e
187
+ # File too large or other error
188
+ file_size = File.size(path)
189
+ ext = File.extname(path).downcase
190
+ {
191
+ path: path,
192
+ binary: true,
193
+ format: ext.empty? ? "unknown" : ext[1..-1],
194
+ size_bytes: file_size,
195
+ content: nil,
196
+ error: e.message
197
+ }
198
+ end
199
+ else
200
+ # Binary file that we can't send to LLM
201
+ file_size = File.size(path)
202
+ ext = File.extname(path).downcase
203
+ {
204
+ path: path,
205
+ binary: true,
206
+ format: ext.empty? ? "unknown" : ext[1..-1],
207
+ size_bytes: file_size,
208
+ content: nil,
209
+ error: "Binary file detected. This format cannot be read as text. File size: #{format_file_size(file_size)}"
210
+ }
211
+ end
212
+ end
213
+
214
+ private def detect_mime_type(path, data)
215
+ Utils::FileProcessor.detect_mime_type(path, data)
216
+ end
217
+
218
+ private def format_file_size(bytes)
219
+ if bytes < 1024
220
+ "#{bytes} bytes"
221
+ elsif bytes < 1024 * 1024
222
+ "#{(bytes / 1024.0).round(2)} KB"
223
+ else
224
+ "#{(bytes / (1024.0 * 1024)).round(2)} MB"
225
+ end
226
+ end
94
227
 
95
228
  private
96
229
 
97
230
  # List first-level directory contents (files and directories)
98
- def list_directory_contents(path)
231
+ private def list_directory_contents(path)
99
232
  begin
100
233
  entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
101
234
 
@@ -190,6 +190,24 @@ module Clacky
190
190
  end
191
191
  end
192
192
 
193
+ # Override format_result_for_llm to preserve security fields
194
+ def format_result_for_llm(result)
195
+ # If security blocked, return as-is (small and important)
196
+ return result if result[:security_blocked]
197
+
198
+ # Call parent's format_result_for_llm to truncate output
199
+ compact = super(result)
200
+
201
+ # Add security enhancement fields if present (they're small and important for LLM to understand)
202
+ if result[:security_enhanced]
203
+ compact[:security_enhanced] = true
204
+ compact[:original_command] = result[:original_command]
205
+ compact[:safe_command] = result[:safe_command]
206
+ end
207
+
208
+ compact
209
+ end
210
+
193
211
  private def format_non_zero_exit(exit_code, stdout, stderr)
194
212
  stdout_lines = stdout.lines.size
195
213
  has_output = stdout_lines > 0
@@ -331,7 +349,12 @@ module Clacky
331
349
 
332
350
  def replace_chmod_command(command)
333
351
  # Parse chmod command to ensure it's safe
334
- parts = Shellwords.split(command)
352
+ begin
353
+ parts = Shellwords.split(command)
354
+ rescue Shellwords::BadQuotedString => e
355
+ # If Shellwords.split fails, use simple split as fallback
356
+ parts = command.split(/\s+/)
357
+ end
335
358
 
336
359
  # Only allow chmod +x on files in project directory
337
360
  files = parts[2..-1] || []
@@ -340,8 +363,6 @@ module Clacky
340
363
  # Allow chmod +x as it's generally safe
341
364
  log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
342
365
  command
343
- rescue Shellwords::BadQuotedString
344
- raise SecurityError, "Invalid chmod command syntax: #{command}"
345
366
  end
346
367
 
347
368
  def replace_curl_pipe_command(command)
@@ -370,7 +391,14 @@ module Clacky
370
391
 
371
392
  def validate_and_allow(command)
372
393
  # Check basic file operation commands
373
- parts = Shellwords.split(command)
394
+ begin
395
+ parts = Shellwords.split(command)
396
+ rescue Shellwords::BadQuotedString => e
397
+ # If Shellwords.split fails due to quote issues, try simple split as fallback
398
+ # This handles cases where paths don't actually need shell escaping
399
+ parts = command.split(/\s+/)
400
+ end
401
+
374
402
  cmd = parts.first
375
403
  args = parts[1..-1]
376
404
 
@@ -384,8 +412,6 @@ module Clacky
384
412
  end
385
413
 
386
414
  command
387
- rescue Shellwords::BadQuotedString
388
- raise SecurityError, "Invalid command syntax: #{command}"
389
415
  end
390
416
 
391
417
  def validate_general_command(command)
@@ -419,11 +445,15 @@ module Clacky
419
445
  end
420
446
 
421
447
  def parse_rm_files(command)
422
- parts = Shellwords.split(command)
448
+ begin
449
+ parts = Shellwords.split(command)
450
+ rescue Shellwords::BadQuotedString => e
451
+ # If Shellwords.split fails, use simple split as fallback
452
+ parts = command.split(/\s+/)
453
+ end
454
+
423
455
  # Skip rm command itself and option parameters
424
456
  parts.drop(1).reject { |part| part.start_with?('-') }
425
- rescue Shellwords::BadQuotedString
426
- raise SecurityError, "Invalid command syntax: #{command}"
427
457
  end
428
458
 
429
459
  def validate_file_path(path)
@@ -290,6 +290,73 @@ module Clacky
290
290
  "[Exit #{exit_code}] #{error_msg[0..50]}"
291
291
  end
292
292
  end
293
+
294
+ # Format result for LLM consumption - limit output size to save tokens
295
+ # Maximum characters to include in LLM output
296
+ MAX_LLM_OUTPUT_CHARS = 2000
297
+
298
+ def format_result_for_llm(result)
299
+ # Return error info as-is if command failed or timed out
300
+ return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
301
+
302
+ stdout = result[:stdout] || ""
303
+ stderr = result[:stderr] || ""
304
+ exit_code = result[:exit_code] || 0
305
+
306
+ # Build compact result with truncated output
307
+ compact = {
308
+ command: result[:command],
309
+ exit_code: exit_code,
310
+ success: result[:success]
311
+ }
312
+
313
+ # Add elapsed time if available
314
+ compact[:elapsed] = result[:elapsed] if result[:elapsed]
315
+
316
+ # Truncate stdout to save tokens
317
+ if stdout.empty?
318
+ compact[:stdout] = ""
319
+ else
320
+ stdout_lines = stdout.lines
321
+ stdout_line_count = stdout_lines.length
322
+
323
+ if stdout.length <= MAX_LLM_OUTPUT_CHARS
324
+ compact[:stdout] = stdout
325
+ else
326
+ # Take first N lines that fit within the character limit
327
+ accumulated_chars = 0
328
+ lines_to_include = []
329
+
330
+ stdout_lines.each do |line|
331
+ break if accumulated_chars + line.length > MAX_LLM_OUTPUT_CHARS
332
+ lines_to_include << line
333
+ accumulated_chars += line.length
334
+ end
335
+
336
+ compact[:stdout] = lines_to_include.join
337
+ compact[:stdout] += "\n\n... [Output truncated for LLM: showing #{lines_to_include.length} of #{stdout_line_count} lines, #{accumulated_chars} of #{stdout.length} chars] ...\n"
338
+ end
339
+ end
340
+
341
+ # Truncate stderr to save tokens (usually more important than stdout, so keep more)
342
+ if stderr.empty?
343
+ compact[:stderr] = ""
344
+ else
345
+ stderr_line_count = stderr.lines.length
346
+
347
+ if stderr.length <= MAX_LLM_OUTPUT_CHARS
348
+ compact[:stderr] = stderr
349
+ else
350
+ compact[:stderr] = stderr[0...MAX_LLM_OUTPUT_CHARS]
351
+ compact[:stderr] += "\n\n... [Error output truncated for LLM: showing #{MAX_LLM_OUTPUT_CHARS} of #{stderr.length} chars, #{stderr_line_count} lines] ...\n"
352
+ end
353
+ end
354
+
355
+ # Add output_truncated flag
356
+ compact[:output_truncated] = result[:output_truncated] if result[:output_truncated]
357
+
358
+ compact
359
+ end
293
360
  end
294
361
  end
295
362
  end
@@ -84,8 +84,15 @@ module Clacky
84
84
  # When paused (InlineInput active), don't take up any space
85
85
  return 0 if @paused
86
86
 
87
- height = 1 # Session bar (top)
88
- height += 1 # Separator after session bar
87
+ height = 0
88
+
89
+ # Session bar - calculate actual wrapped height
90
+ height += calculate_sessionbar_height
91
+
92
+ # Separator after session bar
93
+ height += 1
94
+
95
+ # Images
89
96
  height += @images.size
90
97
 
91
98
  # Calculate height considering wrapped lines
@@ -101,9 +108,13 @@ module Clacky
101
108
  height += wrapped_segments.size
102
109
  end
103
110
 
104
- height += 1 # Bottom separator
111
+ # Bottom separator
112
+ height += 1
113
+
114
+ # Tips and user tips
105
115
  height += 1 if @tips_message
106
116
  height += 1 if @user_tip
117
+
107
118
  height
108
119
  end
109
120
 
@@ -202,49 +213,7 @@ module Clacky
202
213
  end
203
214
 
204
215
  # Input lines with auto-wrap support
205
- @lines.each_with_index do |line, idx|
206
- prefix = if idx == 0
207
- prompt_text = theme.format_symbol(:user) + " "
208
- prompt_text
209
- else
210
- " " * prompt.length
211
- end
212
-
213
- # Calculate available width for text (excluding prefix)
214
- prefix_width = calculate_display_width(strip_ansi_codes(prefix))
215
- available_width = @width - prefix_width
216
-
217
- # Wrap line if needed
218
- wrapped_segments = wrap_line(line, available_width)
219
-
220
- wrapped_segments.each_with_index do |segment_info, wrap_idx|
221
- move_cursor(current_row, 0)
222
-
223
- segment_text = segment_info[:text]
224
- segment_start = segment_info[:start]
225
- segment_end = segment_info[:end]
226
-
227
- content = if wrap_idx == 0
228
- # First wrapped line includes prefix
229
- if idx == @line_index
230
- "#{prefix}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
231
- else
232
- "#{prefix}#{theme.format_text(segment_text, :user)}"
233
- end
234
- else
235
- # Continuation lines have indent matching prefix width
236
- continuation_indent = " " * prefix_width
237
- if idx == @line_index
238
- "#{continuation_indent}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
239
- else
240
- "#{continuation_indent}#{theme.format_text(segment_text, :user)}"
241
- end
242
- end
243
-
244
- print_with_padding(content)
245
- current_row += 1
246
- end
247
- end
216
+ current_row = render_input_lines(current_row)
248
217
 
249
218
  # Bottom separator
250
219
  render_separator(current_row)
@@ -561,6 +530,73 @@ module Clacky
561
530
 
562
531
  private
563
532
 
533
+ # Render all input lines with auto-wrap support
534
+ # @param start_row [Integer] Starting row position
535
+ # @return [Integer] Next available row after rendering all lines
536
+ def render_input_lines(start_row)
537
+ current_row = start_row
538
+
539
+ @lines.each_with_index do |line, line_idx|
540
+ prefix = calculate_line_prefix(line_idx)
541
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
542
+ available_width = @width - prefix_width
543
+ wrapped_segments = wrap_line(line, available_width)
544
+
545
+ wrapped_segments.each_with_index do |segment_info, wrap_idx|
546
+ content = render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
547
+ move_cursor(current_row, 0)
548
+ print_with_padding(content)
549
+ current_row += 1
550
+ end
551
+ end
552
+
553
+ current_row
554
+ end
555
+
556
+ # Calculate the prefix (prompt or indent) for a given line index
557
+ # @param line_idx [Integer] Index of the line
558
+ # @return [String] Prefix string (with formatting)
559
+ private def calculate_line_prefix(line_idx)
560
+ if line_idx == 0
561
+ theme.format_symbol(:user) + " "
562
+ else
563
+ " " * prompt.length
564
+ end
565
+ end
566
+
567
+ # Render a single segment of a line (handling cursor and wrapping)
568
+ # @param line [String] Full line text
569
+ # @param line_idx [Integer] Index of the line in @lines
570
+ # @param segment_info [Hash] Segment information from wrap_line
571
+ # @param wrap_idx [Integer] Index of this segment in wrapped segments
572
+ # @param prefix [String] Line prefix (prompt or indent)
573
+ # @param prefix_width [Integer] Display width of the prefix
574
+ # @return [String] Formatted content for this segment
575
+ private def render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
576
+ segment_text = segment_info[:text]
577
+ segment_start = segment_info[:start]
578
+ segment_end = segment_info[:end]
579
+
580
+ is_current_line = (line_idx == @line_index)
581
+ is_first_segment = (wrap_idx == 0)
582
+
583
+ # Determine the line prefix
584
+ line_prefix = if is_first_segment
585
+ prefix
586
+ else
587
+ " " * prefix_width # Continuation indent
588
+ end
589
+
590
+ # Render the segment content (with or without cursor)
591
+ segment_content = if is_current_line
592
+ render_line_segment_with_cursor(line, segment_start, segment_end)
593
+ else
594
+ theme.format_text(segment_text, :user)
595
+ end
596
+
597
+ "#{line_prefix}#{segment_content}"
598
+ end
599
+
564
600
  # Wrap a line into multiple segments based on available width
565
601
  # Considers display width of characters (multi-byte characters like Chinese)
566
602
  # @param line [String] The line to wrap
@@ -646,12 +682,29 @@ module Clacky
646
682
  visible_content = content.gsub(/\e\[[0-9;]*m/, '')
647
683
  visible_width = calculate_display_width(visible_content)
648
684
 
649
- # Print content
650
- print content
651
-
652
- # Pad with spaces if needed to clear old content
653
- remaining = @width - visible_width
654
- print " " * remaining if remaining > 0
685
+ # IMPORTANT: If content exceeds screen width, truncate to prevent terminal auto-wrap
686
+ if visible_width > @width
687
+ # Content too long - truncate to fit (loses ANSI colors but prevents wrapping)
688
+ truncate_at = 0
689
+ current_width = 0
690
+ visible_content.each_char.with_index do |char, idx|
691
+ char_width = char_display_width(char)
692
+ break if current_width + char_width + 3 > @width # Reserve 3 for "..."
693
+ current_width += char_width
694
+ truncate_at = idx + 1
695
+ end
696
+ print visible_content[0...truncate_at]
697
+ print "..."
698
+ # Pad remaining
699
+ remaining = @width - current_width - 3
700
+ print " " * remaining if remaining > 0
701
+ else
702
+ # Content fits - print normally
703
+ print content
704
+ # Pad with spaces if needed to clear old content
705
+ remaining = @width - visible_width
706
+ print " " * remaining if remaining > 0
707
+ end
655
708
  end
656
709
 
657
710
  def handle_enter
@@ -948,22 +1001,46 @@ module Clacky
948
1001
  end
949
1002
  end
950
1003
 
1004
+ # Render a separator line (ensures it doesn't exceed screen width)
1005
+ # @param row [Integer] Row position to render
951
1006
  def render_separator(row)
952
1007
  move_cursor(row, 0)
953
- content = @pastel.dim("─" * @width)
954
- print_with_padding(content)
1008
+ # Ensure separator doesn't exceed screen width to prevent wrapping
1009
+ separator_width = [@width, 1].max
1010
+ content = @pastel.dim("─" * separator_width)
1011
+ print content
1012
+ # Clear any remaining space
1013
+ remaining = @width - separator_width
1014
+ print " " * remaining if remaining > 0
955
1015
  end
956
1016
 
1017
+ # Render session bar with wrapping support
1018
+ # @param row [Integer] Starting row position
1019
+ # @return [Integer] Number of rows actually used
957
1020
  def render_sessionbar(row)
958
1021
  move_cursor(row, 0)
959
1022
 
960
1023
  # If no sessionbar info, just render a separator
961
1024
  unless @sessionbar_info[:working_dir]
962
- content = @pastel.dim("─" * @width)
963
- print_with_padding(content)
964
- return
1025
+ separator_width = [@width, 1].max
1026
+ content = @pastel.dim("─" * separator_width)
1027
+ print content
1028
+ remaining = @width - separator_width
1029
+ print " " * remaining if remaining > 0
1030
+ return 1
965
1031
  end
966
1032
 
1033
+ session_line = build_sessionbar_content
1034
+
1035
+ # IMPORTANT: Always use print_with_padding which handles truncation
1036
+ # to prevent terminal auto-wrap
1037
+ print_with_padding(session_line)
1038
+ 1
1039
+ end
1040
+
1041
+ # Build the session bar content string
1042
+ # @return [String] Formatted session bar content
1043
+ private def build_sessionbar_content
967
1044
  parts = []
968
1045
  separator = @pastel.dim(" │ ")
969
1046
 
@@ -998,8 +1075,47 @@ module Clacky
998
1075
  cost_display = format("$%.1f", @sessionbar_info[:cost])
999
1076
  parts << @pastel.dim(@pastel.white(cost_display))
1000
1077
 
1001
- session_line = " " + parts.join(separator)
1002
- print_with_padding(session_line)
1078
+ " " + parts.join(separator)
1079
+ end
1080
+
1081
+ # Truncate session bar content to fit within max length
1082
+ # @param content [String] Full session bar content with ANSI codes
1083
+ # @param max_length [Integer] Maximum visible length
1084
+ # @return [String] Truncated content
1085
+ private def truncate_sessionbar_content(content, max_length)
1086
+ # Strip ANSI codes to calculate visible length
1087
+ visible_content = strip_ansi_codes(content)
1088
+ visible_width = calculate_display_width(visible_content)
1089
+
1090
+ return content if visible_width <= max_length
1091
+
1092
+ # Truncate from the end with "..." indicator
1093
+ chars = visible_content.chars
1094
+ current_width = 0
1095
+ truncate_at = 0
1096
+
1097
+ chars.each_with_index do |char, idx|
1098
+ char_width = char_display_width(char)
1099
+ if current_width + char_width + 3 > max_length # Reserve 3 for "..."
1100
+ truncate_at = idx
1101
+ break
1102
+ end
1103
+ current_width += char_width
1104
+ truncate_at = idx + 1
1105
+ end
1106
+
1107
+ # For simplicity with ANSI codes, just show first part + ...
1108
+ # This is a simplified version - proper implementation would preserve ANSI codes
1109
+ visible_content[0...truncate_at] + "..."
1110
+ end
1111
+
1112
+ # Calculate how many rows the session bar will occupy
1113
+ # @return [Integer] Number of rows needed
1114
+ private def calculate_sessionbar_height
1115
+ return 1 unless @sessionbar_info[:working_dir]
1116
+
1117
+ # Session bar always renders on one line (we truncate if needed)
1118
+ 1
1003
1119
  end
1004
1120
 
1005
1121
  def shorten_path(path)