openclacky 0.6.2 → 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.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tmpdir"
3
4
  require_relative "base"
4
5
 
5
6
  module Clacky
@@ -35,7 +36,7 @@ module Clacky
35
36
  INTERACTION_PATTERNS = [
36
37
  [/\[Y\/n\]|\[y\/N\]|\(yes\/no\)|\(Y\/n\)|\(y\/N\)/i, 'confirmation'],
37
38
  [/[Pp]assword\s*:\s*$|Enter password|enter password/, 'password'],
38
- [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^>\s*$/, 'repl'],
39
+ [/^\s*>>>\s*$|^\s*>>?\s*$|^irb\(.*\):\d+:\d+[>*]\s*$|^\>\s*$/, 'repl'],
39
40
  [/^\s*:\s*$|\(END\)|--More--|Press .* to continue|lines \d+-\d+/, 'pager'],
40
41
  [/Are you sure|Continue\?|Proceed\?|Confirm|Overwrite/i, 'question'],
41
42
  [/Enter\s+\w+:|Input\s+\w+:|Please enter|please provide/i, 'input'],
@@ -143,7 +144,7 @@ module Clacky
143
144
 
144
145
  stdout_output = stdout_buffer.string
145
146
  stderr_output = stderr_buffer.string
146
-
147
+
147
148
  {
148
149
  command: command,
149
150
  stdout: truncate_output(stdout_output, max_output_lines),
@@ -157,7 +158,7 @@ module Clacky
157
158
  rescue StandardError => e
158
159
  stdout_output = stdout_buffer.string
159
160
  stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
160
-
161
+
161
162
  {
162
163
  command: command,
163
164
  stdout: truncate_output(stdout_output, max_output_lines),
@@ -253,10 +254,10 @@ module Clacky
253
254
  # Truncate output to max_lines, adding a truncation notice if needed
254
255
  def truncate_output(output, max_lines)
255
256
  return output if output.nil? || output.empty?
256
-
257
+
257
258
  lines = output.lines
258
259
  return output if lines.length <= max_lines
259
-
260
+
260
261
  truncated_lines = lines.first(max_lines)
261
262
  truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
262
263
  truncated_lines.join + truncation_notice
@@ -293,69 +294,105 @@ module Clacky
293
294
 
294
295
  # Format result for LLM consumption - limit output size to save tokens
295
296
  # Maximum characters to include in LLM output
296
- MAX_LLM_OUTPUT_CHARS = 2000
297
-
297
+ MAX_LLM_OUTPUT_CHARS = 1000
298
+
298
299
  def format_result_for_llm(result)
299
300
  # Return error info as-is if command failed or timed out
300
301
  return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
301
-
302
+
302
303
  stdout = result[:stdout] || ""
303
304
  stderr = result[:stderr] || ""
304
305
  exit_code = result[:exit_code] || 0
305
-
306
+
306
307
  # Build compact result with truncated output
307
308
  compact = {
308
309
  command: result[:command],
309
310
  exit_code: exit_code,
310
311
  success: result[:success]
311
312
  }
312
-
313
- # Add elapsed time if available
313
+
314
+ # Add elapsed time if available (keep original precision)
314
315
  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
316
+
317
+ # Extract command name for temp file naming
318
+ command_name = extract_command_name(result[:command])
319
+
320
+ # Process stdout: truncate and optionally save to temp file
321
+ stdout_info = truncate_and_save(stdout, MAX_LLM_OUTPUT_CHARS, "stdout", command_name)
322
+ compact[:stdout] = stdout_info[:content]
323
+ compact[:stdout_full] = stdout_info[:temp_file] if stdout_info[:temp_file]
324
+
325
+ # Process stderr: truncate and optionally save to temp file
326
+ stderr_info = truncate_and_save(stderr, MAX_LLM_OUTPUT_CHARS, "stderr", command_name)
327
+ compact[:stderr] = stderr_info[:content]
328
+ compact[:stderr_full] = stderr_info[:temp_file] if stderr_info[:temp_file]
329
+
330
+ # Add output_truncated flag if present
331
+ compact[:output_truncated] = true if result[:output_truncated]
332
+
333
+ compact
334
+ end
335
+
336
+ # Extract command name from full command for temp file naming
337
+ def extract_command_name(command)
338
+ first_word = command.strip.split(/\s+/).first
339
+ File.basename(first_word, ".*")
340
+ end
341
+
342
+ # Truncate output for LLM and optionally save full content to temp file
343
+ def truncate_and_save(output, max_chars, label, command_name)
344
+ return { content: "", temp_file: nil } if output.empty?
345
+
346
+ return { content: output, temp_file: nil } if output.length <= max_chars
347
+
348
+ # Sanitize command name for file name
349
+ safe_name = command_name.gsub(/[^\w\-.]/, "_")[0...50]
350
+
351
+ # Use Ruby tmpdir for safe temp file creation
352
+ temp_dir = Dir.mktmpdir
353
+ temp_file = File.join(temp_dir, "#{safe_name}_#{Time.now.strftime("%Y%m%d_%H%M%S")}.output")
354
+
355
+ # Write full output to temp file
356
+ File.write(temp_file, output)
357
+
358
+ # For LLM display: show first and last lines to preserve structure
359
+ lines = output.lines
360
+ return { content: output, temp_file: nil } if lines.length <= 2
361
+
362
+ # Reserve 60 chars for truncation notice
363
+ available_chars = max_chars - 60
364
+
365
+ # Take first few lines
366
+ first_part = []
367
+ accumulated = 0
368
+ lines.each do |line|
369
+ break if accumulated + line.length > available_chars / 2
370
+ first_part << line
371
+ accumulated += line.length
339
372
  end
340
-
341
- # Truncate stderr to save tokens (usually more important than stdout, so keep more)
342
- if stderr.empty?
343
- compact[:stderr] = ""
373
+
374
+ # Take last few lines
375
+ last_part = []
376
+ accumulated = 0
377
+ lines.reverse_each do |line|
378
+ break if accumulated + line.length > available_chars / 2
379
+ last_part.unshift(line)
380
+ accumulated += line.length
381
+ end
382
+
383
+ total_lines = lines.length
384
+
385
+ # Create notice message based on label
386
+ if label == "stderr"
387
+ notice = "... [Error output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
344
388
  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
389
+ notice = "... [Output truncated for LLM: showing #{first_part.length} of #{total_lines} lines, full content: #{temp_file} (use grep to search)] ..."
353
390
  end
354
-
355
- # Add output_truncated flag
356
- compact[:output_truncated] = result[:output_truncated] if result[:output_truncated]
357
-
358
- compact
391
+
392
+ # Combine with compact notice
393
+ content = first_part.join + "\n#{notice}\n" + last_part.join
394
+
395
+ { content: content, temp_file: temp_file }
359
396
  end
360
397
  end
361
398
  end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require "net/http"
4
4
  require "uri"
5
+ require "tmpdir"
6
+ require "fileutils"
5
7
 
6
8
  module Clacky
7
9
  module Tools
@@ -18,14 +20,14 @@ module Clacky
18
20
  },
19
21
  max_length: {
20
22
  type: "integer",
21
- description: "Maximum content length to return in characters (default: 50000)",
22
- default: 50000
23
+ description: "Maximum content length to return in characters (default: 3000)",
24
+ default: 3000
23
25
  }
24
26
  },
25
27
  required: %w[url]
26
28
  }
27
29
 
28
- def execute(url:, max_length: 50000)
30
+ def execute(url:, max_length: 3000)
29
31
  # Validate URL
30
32
  begin
31
33
  uri = URI.parse(url)
@@ -46,7 +48,7 @@ module Clacky
46
48
 
47
49
  # Parse HTML if it's an HTML page
48
50
  if content_type.include?("text/html")
49
- result = parse_html(content, max_length)
51
+ result = parse_html(content, max_length, url)
50
52
  result[:url] = url
51
53
  result[:content_type] = content_type
52
54
  result[:status_code] = response.code.to_i
@@ -54,21 +56,45 @@ module Clacky
54
56
  result
55
57
  else
56
58
  # For non-HTML content, return raw text
57
- truncated_content = content[0, max_length]
58
- {
59
- url: url,
60
- content_type: content_type,
61
- status_code: response.code.to_i,
62
- content: truncated_content,
63
- truncated: content.length > max_length,
64
- error: nil
65
- }
59
+ result = handle_raw_content(content, max_length, url, content_type, response.code.to_i)
60
+ result
66
61
  end
67
62
  rescue StandardError => e
68
63
  { error: "Failed to fetch URL: #{e.message}" }
69
64
  end
70
65
  end
71
66
 
67
+ def handle_raw_content(content, max_length, url, content_type, status_code)
68
+ truncated = content.length > max_length
69
+ temp_file = nil
70
+
71
+ if truncated
72
+ temp_dir = Dir.mktmpdir
73
+ domain = extract_domain(url)
74
+ safe_name = domain.gsub(/[^\w\-.]/, '_')[0...50]
75
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
76
+ temp_file = File.join(temp_dir, "#{safe_name}_#{timestamp}.txt")
77
+ File.write(temp_file, content)
78
+ end
79
+
80
+ {
81
+ url: url,
82
+ content_type: content_type,
83
+ status_code: status_code,
84
+ content: content[0, max_length],
85
+ truncated: truncated,
86
+ temp_file: temp_file,
87
+ error: nil
88
+ }
89
+ end
90
+
91
+ def extract_domain(url)
92
+ uri = URI.parse(url)
93
+ uri.host || url.gsub(/[^\w\-.]/, '_')
94
+ rescue
95
+ url.gsub(/[^\w\-.]/, '_')
96
+ end
97
+
72
98
  def fetch_url(uri)
73
99
  # Follow redirects (max 5)
74
100
  redirects = 0
@@ -97,7 +123,7 @@ module Clacky
97
123
  end
98
124
  end
99
125
 
100
- def parse_html(html, max_length)
126
+ def parse_html(html, max_length, url = nil)
101
127
  # Extract title
102
128
  title = ""
103
129
  if html =~ %r{<title[^>]*>(.*?)</title>}mi
@@ -122,15 +148,25 @@ module Clacky
122
148
  # Clean up whitespace
123
149
  text = text.gsub(/\s+/, " ").strip
124
150
 
125
- # Truncate if needed
151
+ # Check if we need to save to temp file
126
152
  truncated = text.length > max_length
127
- text = text[0, max_length] if truncated
153
+ temp_file = nil
154
+
155
+ if truncated
156
+ temp_dir = Dir.mktmpdir
157
+ domain = url ? extract_domain(url) : "web_fetch"
158
+ safe_name = domain.gsub(/[^\w\-.]/, '_')[0...50]
159
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
160
+ temp_file = File.join(temp_dir, "#{safe_name}_#{timestamp}.txt")
161
+ File.write(temp_file, text)
162
+ end
128
163
 
129
164
  {
130
165
  title: title,
131
166
  description: description,
132
- content: text,
133
- truncated: truncated
167
+ content: text[0, max_length],
168
+ truncated: truncated,
169
+ temp_file: temp_file
134
170
  }
135
171
  end
136
172
 
@@ -156,6 +192,33 @@ module Clacky
156
192
  "[OK] Fetched: #{display_title}"
157
193
  end
158
194
  end
195
+
196
+ # Format result for LLM consumption - return compact version to save tokens
197
+ def format_result_for_llm(result)
198
+ # Return error as-is
199
+ return result if result[:error]
200
+
201
+ # Build compact result
202
+ compact = {
203
+ url: result[:url],
204
+ title: result[:title],
205
+ description: result[:description],
206
+ status_code: result[:status_code]
207
+ }
208
+
209
+ # Add truncated notice and temp file info if content was truncated
210
+ if result[:truncated] && result[:temp_file]
211
+ compact[:content] = result[:content]
212
+ compact[:truncated] = true
213
+ compact[:temp_file] = result[:temp_file]
214
+ compact[:message] = "[Content truncated - full content saved to temp file. Use file_reader to read it if needed.]"
215
+ else
216
+ compact[:content] = result[:content]
217
+ compact[:truncated] = result[:truncated] || false
218
+ end
219
+
220
+ compact
221
+ end
159
222
  end
160
223
  end
161
224
  end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require_relative "../theme_manager"
5
+
6
+ module Clacky
7
+ module UI2
8
+ module Components
9
+ # CommandSuggestions displays a dropdown menu of available commands
10
+ # Supports keyboard navigation and filtering
11
+ class CommandSuggestions
12
+ attr_reader :selected_index, :visible
13
+
14
+ # System commands available by default
15
+ SYSTEM_COMMANDS = [
16
+ { command: "/clear", description: "Clear chat history and restart session" },
17
+ { command: "/help", description: "Show help information" },
18
+ { command: "/exit", description: "Exit the chat session" },
19
+ { command: "/quit", description: "Quit the application" }
20
+ ].freeze
21
+
22
+ def initialize
23
+ @pastel = Pastel.new
24
+ @commands = []
25
+ @filtered_commands = []
26
+ @selected_index = 0
27
+ @visible = false
28
+ @filter_text = ""
29
+ @skill_commands = []
30
+
31
+ # Initialize with system commands
32
+ update_commands
33
+ end
34
+
35
+ # Get current theme from ThemeManager
36
+ def theme
37
+ UI2::ThemeManager.current_theme
38
+ end
39
+
40
+ # Load skill commands from skill loader
41
+ # @param skill_loader [Clacky::SkillLoader] The skill loader instance
42
+ def load_skill_commands(skill_loader)
43
+ return unless skill_loader
44
+
45
+ @skill_commands = skill_loader.user_invocable_skills.map do |skill|
46
+ {
47
+ command: skill.slash_command,
48
+ description: skill.description || "No description available",
49
+ type: :skill
50
+ }
51
+ end
52
+
53
+ update_commands
54
+ end
55
+
56
+ # Show the suggestions dropdown
57
+ # @param filter_text [String] Initial filter text (everything after the /)
58
+ def show(filter_text = "")
59
+ @filter_text = filter_text
60
+ @visible = true
61
+ update_filtered_commands
62
+ @selected_index = 0
63
+ end
64
+
65
+ # Hide the suggestions dropdown
66
+ def hide
67
+ @visible = false
68
+ @filter_text = ""
69
+ @filtered_commands = []
70
+ @selected_index = 0
71
+ end
72
+
73
+ # Update filter text and refresh filtered commands
74
+ # @param text [String] Filter text (everything after the /)
75
+ def update_filter(text)
76
+ @filter_text = text
77
+ update_filtered_commands
78
+ @selected_index = 0 # Reset selection when filter changes
79
+ end
80
+
81
+ # Move selection up
82
+ def select_previous
83
+ return if @filtered_commands.empty?
84
+ @selected_index = (@selected_index - 1) % @filtered_commands.size
85
+ end
86
+
87
+ # Move selection down
88
+ def select_next
89
+ return if @filtered_commands.empty?
90
+ @selected_index = (@selected_index + 1) % @filtered_commands.size
91
+ end
92
+
93
+ # Get the currently selected command
94
+ # @return [Hash, nil] Selected command hash or nil if none selected
95
+ def selected_command
96
+ return nil if @filtered_commands.empty?
97
+ @filtered_commands[@selected_index]
98
+ end
99
+
100
+ # Get the currently selected command text
101
+ # @return [String, nil] Selected command text or nil if none selected
102
+ def selected_command_text
103
+ cmd = selected_command
104
+ cmd ? cmd[:command] : nil
105
+ end
106
+
107
+ # Check if there are any suggestions to show
108
+ # @return [Boolean]
109
+ def has_suggestions?
110
+ @visible && !@filtered_commands.empty?
111
+ end
112
+
113
+ # Calculate required height for rendering
114
+ # @return [Integer] Number of lines needed
115
+ def required_height
116
+ return 0 unless @visible
117
+ return 0 if @filtered_commands.empty?
118
+
119
+ # Header + commands + footer
120
+ 1 + [@filtered_commands.size, 5].min + 1 # Max 5 visible items
121
+ end
122
+
123
+ # Render the suggestions dropdown
124
+ # @param row [Integer] Starting row position
125
+ # @param col [Integer] Starting column position
126
+ # @param width [Integer] Maximum width for the dropdown
127
+ # @return [String] Rendered output
128
+ def render(row:, col:, width: 60)
129
+ return "" unless @visible
130
+ return "" if @filtered_commands.empty?
131
+
132
+ output = []
133
+ max_items = 5 # Maximum visible items
134
+ visible_commands = @filtered_commands.take(max_items)
135
+
136
+ # Header
137
+ header = @pastel.dim("┌─ Commands ") + @pastel.dim("─" * (width - 13)) + @pastel.dim("┐")
138
+ output << position_cursor(row, col) + header
139
+
140
+ # Items
141
+ visible_commands.each_with_index do |cmd, idx|
142
+ is_selected = (idx == @selected_index)
143
+ line = render_command_item(cmd, is_selected, width)
144
+ output << position_cursor(row + 1 + idx, col) + line
145
+ end
146
+
147
+ # Footer with navigation hint
148
+ footer_row = row + 1 + visible_commands.size
149
+ total = @filtered_commands.size
150
+ hint = total > max_items ? " (#{total - max_items} more...)" : ""
151
+ footer = @pastel.dim("└") + @pastel.dim("─" * (width - 2)) + @pastel.dim("┘")
152
+ output << position_cursor(footer_row, col) + footer
153
+
154
+ output.join
155
+ end
156
+
157
+ # Clear the rendered dropdown from screen
158
+ # @param row [Integer] Starting row position
159
+ # @param col [Integer] Starting column position
160
+ def clear_from_screen(row:, col:)
161
+ return unless @visible
162
+
163
+ height = required_height
164
+ output = []
165
+
166
+ height.times do |i|
167
+ output << position_cursor(row + i, col) + clear_line
168
+ end
169
+
170
+ print output.join
171
+ flush
172
+ end
173
+
174
+ private
175
+
176
+ # Update the complete commands list (system + skills)
177
+ private def update_commands
178
+ system_cmds = SYSTEM_COMMANDS.map { |c| c.merge(type: :system) }
179
+ @commands = system_cmds + @skill_commands
180
+ update_filtered_commands if @visible
181
+ end
182
+
183
+ # Update filtered commands based on current filter text
184
+ private def update_filtered_commands
185
+ if @filter_text.empty?
186
+ @filtered_commands = @commands
187
+ else
188
+ filter_lower = @filter_text.downcase
189
+ @filtered_commands = @commands.select do |cmd|
190
+ # Remove leading / for comparison
191
+ cmd_name = cmd[:command].sub(/^\//, "")
192
+ cmd_name.downcase.start_with?(filter_lower) ||
193
+ cmd[:description].downcase.include?(filter_lower)
194
+ end
195
+ end
196
+ end
197
+
198
+ # Render a single command item
199
+ # @param cmd [Hash] Command hash with :command and :description
200
+ # @param selected [Boolean] Whether this item is selected
201
+ # @param width [Integer] Maximum width
202
+ # @return [String] Rendered item
203
+ private def render_command_item(cmd, selected, width)
204
+ # Calculate available space
205
+ available = width - 4 # Account for borders and padding
206
+
207
+ # Format command (e.g., "/clear")
208
+ command_text = cmd[:command]
209
+
210
+ # Format description
211
+ max_desc_length = available - command_text.length - 3 # 3 for spacing
212
+ description = truncate_text(cmd[:description], max_desc_length)
213
+
214
+ # Build line
215
+ if selected
216
+ # Highlighted selection
217
+ line = @pastel.on_blue(@pastel.white(" #{command_text} "))
218
+ line += @pastel.on_blue(@pastel.dim(" #{description}"))
219
+ # Pad to full width
220
+ content_length = command_text.length + description.length + 2
221
+ padding = " " * [available - content_length, 0].max
222
+ line += @pastel.on_blue(padding)
223
+ @pastel.dim("│") + line + @pastel.dim("│")
224
+ else
225
+ # Normal item
226
+ line = " #{@pastel.cyan(command_text)} #{@pastel.dim(description)}"
227
+ # Pad to full width
228
+ content_length = strip_ansi(line).length
229
+ padding = " " * [available - content_length, 0].max
230
+ @pastel.dim("│") + line + padding + @pastel.dim("│")
231
+ end
232
+ end
233
+
234
+ # Truncate text to maximum length
235
+ # @param text [String] Text to truncate
236
+ # @param max_length [Integer] Maximum length
237
+ # @return [String] Truncated text
238
+ private def truncate_text(text, max_length)
239
+ return "" if max_length <= 3
240
+ return text if text.length <= max_length
241
+
242
+ text[0...(max_length - 3)] + "..."
243
+ end
244
+
245
+ # Strip ANSI codes from text
246
+ # @param text [String] Text with ANSI codes
247
+ # @return [String] Plain text
248
+ private def strip_ansi(text)
249
+ text.gsub(/\e\[[0-9;]*m/, '')
250
+ end
251
+
252
+ # Position cursor at specific row and column
253
+ # @param row [Integer] Row position (0-indexed)
254
+ # @param col [Integer] Column position (0-indexed)
255
+ # @return [String] ANSI escape sequence
256
+ private def position_cursor(row, col)
257
+ "\e[#{row + 1};#{col + 1}H"
258
+ end
259
+
260
+ # Clear current line
261
+ # @return [String] ANSI escape sequence
262
+ private def clear_line
263
+ "\e[2K"
264
+ end
265
+
266
+ # Flush output to terminal
267
+ private def flush
268
+ $stdout.flush
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end