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,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
@@ -109,24 +109,31 @@ module Clacky
109
109
  # Render inline input with prompt and cursor
110
110
  # @return [String] Rendered line (may wrap to multiple lines)
111
111
  def render
112
- line = render_line_with_cursor
113
- full_text = "#{@prompt}#{line}"
114
-
115
- # Calculate terminal width and check if wrapping is needed
116
112
  width = TTY::Screen.width
117
- visible_text = strip_ansi_codes(full_text)
118
- display_width = calculate_display_width(visible_text)
119
-
120
- # If no wrapping needed, return as is
121
- return full_text if display_width <= width
122
-
123
- # Otherwise, wrap the input (prompt on first line, continuation indented)
124
113
  prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
125
114
  available_width = width - prompt_width
126
-
127
- # For simplicity, just return full text and let terminal handle wrapping
128
- # InlineInput is typically short, so natural wrapping should be fine
129
- full_text
115
+
116
+ # Get wrapped segments
117
+ wrapped_segments = wrap_line(@line, available_width)
118
+
119
+ # Build rendered output with cursor
120
+ output = ""
121
+
122
+ wrapped_segments.each_with_index do |segment, idx|
123
+ prefix = if idx == 0
124
+ @prompt
125
+ else
126
+ "> " # Continuation prompt indicator
127
+ end
128
+
129
+ # Render segment with cursor if needed
130
+ segment_text = render_line_segment_with_cursor(@line, segment[:start], segment[:end])
131
+
132
+ output += "#{prefix}#{segment_text}"
133
+ output += "\n" unless idx == wrapped_segments.size - 1
134
+ end
135
+
136
+ output
130
137
  end
131
138
 
132
139
  # Get cursor column position
@@ -135,6 +142,18 @@ module Clacky
135
142
  cursor_column(@prompt)
136
143
  end
137
144
 
145
+ # Get the number of lines this input will occupy when rendered
146
+ # @param width [Integer] Terminal width
147
+ # @return [Integer] Number of lines
148
+ def line_count(width = TTY::Screen.width)
149
+ prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
150
+ available_width = width - prompt_width
151
+ return 1 if available_width <= 0
152
+
153
+ segments = wrap_line(@line, available_width)
154
+ segments.size
155
+ end
156
+
138
157
  # Deactivate inline input
139
158
  def deactivate
140
159
  @active = false