openclacky 0.5.6 → 0.6.1

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +376 -346
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +167 -398
  7. data/lib/clacky/client.rb +68 -36
  8. data/lib/clacky/gitignore_parser.rb +26 -12
  9. data/lib/clacky/model_pricing.rb +6 -2
  10. data/lib/clacky/session_manager.rb +6 -2
  11. data/lib/clacky/tools/glob.rb +66 -10
  12. data/lib/clacky/tools/grep.rb +6 -122
  13. data/lib/clacky/tools/run_project.rb +10 -5
  14. data/lib/clacky/tools/safe_shell.rb +149 -20
  15. data/lib/clacky/tools/shell.rb +3 -51
  16. data/lib/clacky/tools/todo_manager.rb +50 -3
  17. data/lib/clacky/tools/trash_manager.rb +1 -1
  18. data/lib/clacky/tools/web_fetch.rb +4 -4
  19. data/lib/clacky/tools/web_search.rb +40 -28
  20. data/lib/clacky/ui2/README.md +214 -0
  21. data/lib/clacky/ui2/components/base_component.rb +163 -0
  22. data/lib/clacky/ui2/components/common_component.rb +98 -0
  23. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  24. data/lib/clacky/ui2/components/input_area.rb +1124 -0
  25. data/lib/clacky/ui2/components/message_component.rb +80 -0
  26. data/lib/clacky/ui2/components/output_area.rb +112 -0
  27. data/lib/clacky/ui2/components/todo_area.rb +130 -0
  28. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  29. data/lib/clacky/ui2/components/welcome_banner.rb +103 -0
  30. data/lib/clacky/ui2/layout_manager.rb +437 -0
  31. data/lib/clacky/ui2/line_editor.rb +201 -0
  32. data/lib/clacky/ui2/markdown_renderer.rb +80 -0
  33. data/lib/clacky/ui2/screen_buffer.rb +257 -0
  34. data/lib/clacky/ui2/theme_manager.rb +68 -0
  35. data/lib/clacky/ui2/themes/base_theme.rb +85 -0
  36. data/lib/clacky/ui2/themes/hacker_theme.rb +58 -0
  37. data/lib/clacky/ui2/themes/minimal_theme.rb +52 -0
  38. data/lib/clacky/ui2/ui_controller.rb +778 -0
  39. data/lib/clacky/ui2/view_renderer.rb +177 -0
  40. data/lib/clacky/ui2.rb +37 -0
  41. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  42. data/lib/clacky/version.rb +1 -1
  43. data/lib/clacky.rb +1 -6
  44. metadata +53 -6
  45. data/lib/clacky/ui/banner.rb +0 -155
  46. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  47. data/lib/clacky/ui/formatter.rb +0 -209
  48. data/lib/clacky/ui/statusbar.rb +0 -96
@@ -19,13 +19,9 @@ module Clacky
19
19
  type: "string",
20
20
  description: "Shell command to execute"
21
21
  },
22
- soft_timeout: {
22
+ timeout: {
23
23
  type: "integer",
24
- description: "Soft timeout in seconds (for interaction detection)"
25
- },
26
- hard_timeout: {
27
- type: "integer",
28
- description: "Hard timeout in seconds (force kill)"
24
+ description: "Command timeout in seconds (auto-detected if not specified: 60s for normal commands, 180s for build/install commands)"
29
25
  },
30
26
  max_output_lines: {
31
27
  type: "integer",
@@ -36,19 +32,29 @@ module Clacky
36
32
  required: ["command"]
37
33
  }
38
34
 
39
- def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
35
+ def execute(command:, timeout: nil, max_output_lines: 1000)
40
36
  # Get project root directory
41
37
  project_root = Dir.pwd
42
38
 
43
39
  begin
44
- # 1. Use safety replacer to process command
40
+ # 1. Extract timeout from command if it starts with "timeout N"
41
+ command, extracted_timeout = extract_timeout_from_command(command)
42
+
43
+ # Use extracted timeout if not explicitly provided
44
+ timeout ||= extracted_timeout
45
+
46
+ # 2. Use safety replacer to process command
45
47
  safety_replacer = CommandSafetyReplacer.new(project_root)
46
48
  safe_command = safety_replacer.make_command_safe(command)
47
49
 
48
- # 2. Call parent class execution method
50
+ # 3. Calculate timeouts: soft_timeout is fixed at 5s, hard_timeout from timeout parameter
51
+ soft_timeout = 5
52
+ hard_timeout = calculate_hard_timeout(command, timeout)
53
+
54
+ # 4. Call parent class execution method
49
55
  result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
50
56
 
51
- # 3. Enhance result information
57
+ # 5. Enhance result information
52
58
  enhance_result(result, command, safe_command)
53
59
 
54
60
  rescue SecurityError => e
@@ -56,7 +62,7 @@ module Clacky
56
62
  {
57
63
  command: command,
58
64
  stdout: "",
59
- stderr: "🔒 Security Protection: #{e.message}",
65
+ stderr: "[Security Protection] #{e.message}",
60
66
  exit_code: 126,
61
67
  success: false,
62
68
  security_blocked: true
@@ -64,6 +70,48 @@ module Clacky
64
70
  end
65
71
  end
66
72
 
73
+ private def extract_timeout_from_command(command)
74
+ # Match patterns: "timeout 30 ...", "timeout 30s ...", etc.
75
+ # Also supports: "cd xxx && timeout 30 command", "export X=Y && timeout 30 command"
76
+ # Supports: timeout N command, timeout Ns command, timeout -s SIGNAL N command
77
+
78
+ # Try to match timeout at the beginning of command
79
+ match = command.match(/^timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
80
+
81
+ if match
82
+ timeout_value = match[1].to_i
83
+ actual_command = match[2]
84
+ return [actual_command, timeout_value]
85
+ end
86
+
87
+ # Try to match timeout after && or ;
88
+ # Pattern: "prefix && timeout 30 command" or "prefix; timeout 30 command"
89
+ match = command.match(/^(.+?)\s*(&&|;)\s*timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
90
+
91
+ if match
92
+ prefix = match[1] # e.g., "cd /tmp"
93
+ separator = match[2] # && or ;
94
+ timeout_value = match[3].to_i
95
+ main_command = match[4] # e.g., "bundle exec rspec"
96
+
97
+ # Reconstruct command without timeout prefix
98
+ actual_command = "#{prefix} #{separator} #{main_command}"
99
+ return [actual_command, timeout_value]
100
+ end
101
+
102
+ # No timeout found, return original command
103
+ [command, nil]
104
+ end
105
+
106
+ private def calculate_hard_timeout(command, timeout)
107
+ # If timeout is provided, use it directly
108
+ return timeout if timeout
109
+
110
+ # Otherwise, auto-detect based on command type
111
+ is_slow = SLOW_COMMANDS.any? { |slow_cmd| command.include?(slow_cmd) }
112
+ is_slow ? 180 : 60
113
+ end
114
+
67
115
  # Safe read-only commands that don't modify system state
68
116
  SAFE_READONLY_COMMANDS = %w[
69
117
  ls pwd cat less more head tail
@@ -105,7 +153,7 @@ module Clacky
105
153
  result[:safe_command] = safe_command
106
154
 
107
155
  # Add security note to stdout
108
- security_note = "🔒 Command was automatically made safe\n"
156
+ security_note = "[Safe] Command was automatically made safe\n"
109
157
  result[:stdout] = security_note + (result[:stdout] || "")
110
158
  end
111
159
 
@@ -130,17 +178,92 @@ module Clacky
130
178
  stderr = result[:stderr] || result['stderr'] || ""
131
179
 
132
180
  if result[:security_blocked]
133
- "🔒 Blocked for security"
181
+ "[Blocked] Security protection"
134
182
  elsif result[:security_enhanced]
135
183
  lines = stdout.lines.size
136
- "🔒✓ Safe execution#{lines > 0 ? " (#{lines} lines)" : ''}"
184
+ "[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
137
185
  elsif exit_code == 0
138
186
  lines = stdout.lines.size
139
- " Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
187
+ "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
188
+ else
189
+ format_non_zero_exit(exit_code, stdout, stderr)
190
+ end
191
+ end
192
+
193
+ private def format_non_zero_exit(exit_code, stdout, stderr)
194
+ stdout_lines = stdout.lines.size
195
+ has_output = stdout_lines > 0
196
+ has_error = !stderr.empty?
197
+
198
+ if has_error
199
+ # Real error: show error summary
200
+ error_summary = extract_error_summary(stderr)
201
+ "[Exit #{exit_code}] #{error_summary}"
202
+ elsif has_output
203
+ # Command produced output but exited with non-zero code
204
+ # This is common in commands like "ls; exit 1" or grep with no matches
205
+ "[Exit #{exit_code}] #{stdout_lines} lines output"
140
206
  else
141
- error_msg = stderr.lines.first&.strip || "Failed"
142
- "Exit #{exit_code}: #{error_msg[0..50]}"
207
+ # No output, no error message - just show exit code
208
+ "[Exit #{exit_code}] No output"
209
+ end
210
+ end
211
+
212
+ private def extract_error_summary(stderr)
213
+ return "No error message" if stderr.empty?
214
+
215
+ # Try to extract the most meaningful error line
216
+ lines = stderr.lines.map(&:strip).reject(&:empty?)
217
+
218
+ # Common error patterns with priority
219
+ patterns = [
220
+ # Ruby/Python exceptions with error type
221
+ { regex: /(\w+(?:Error|Exception)):\s*(.+)$/, format: ->(m) { "#{m[1]}: #{m[2]}" } },
222
+ # File not found patterns
223
+ { regex: /cannot load such file.*--\s*(.+)$/, format: ->(m) { "Cannot load file: #{m[1]}" } },
224
+ { regex: /No such file or directory.*[@\-]\s*(.+)$/, format: ->(m) { "File not found: #{m[1]}" } },
225
+ # Undefined method/variable
226
+ { regex: /undefined (?:local variable or )?method [`'](\w+)'/, format: ->(m) { "Undefined method: #{m[1]}" } },
227
+ # Syntax errors
228
+ { regex: /syntax error,?\s*(.+)$/i, format: ->(m) { "Syntax error: #{m[1]}" } }
229
+ ]
230
+
231
+ # Try each pattern on each line
232
+ patterns.each do |pattern|
233
+ lines.each do |line|
234
+ match = line.match(pattern[:regex])
235
+ if match
236
+ result = pattern[:format].call(match)
237
+ return truncate_error(clean_path(result), 80)
238
+ end
239
+ end
240
+ end
241
+
242
+ # Fallback: find the most informative line
243
+ informative_line = lines.find do |line|
244
+ !line.start_with?('from', 'Did you mean?', '#', 'Showing full backtrace') &&
245
+ line.length > 10 &&
246
+ (line.include?(':') || line.match?(/error|failed|cannot|invalid/i))
247
+ end
248
+
249
+ if informative_line
250
+ return truncate_error(clean_path(informative_line), 80)
143
251
  end
252
+
253
+ # Last resort: use first meaningful line
254
+ first_line = lines.first || "Unknown error"
255
+ truncate_error(clean_path(first_line), 80)
256
+ end
257
+
258
+ private def clean_path(text)
259
+ # Remove long absolute paths, keep only filename
260
+ text.gsub(/\/(?:Users|home)\/[^\/]+\/[\w\/\.\-]+\/([^:\/\s]+)/, '')
261
+ .gsub(/\/[\w\/\.\-]{30,}\/([^:\/\s]+)/, '...')
262
+ end
263
+
264
+ private def truncate_error(text, max_length)
265
+ return text if text.length <= max_length
266
+ "#{text[0...max_length-3]}..."
144
267
  end
145
268
  end
146
269
 
@@ -267,12 +390,18 @@ module Clacky
267
390
 
268
391
  def validate_general_command(command)
269
392
  # Check general command security
393
+ # Note: We need to be careful not to match patterns inside quoted strings
394
+
395
+ # First, remove quoted strings to avoid false positives
396
+ # This is a simplified approach - removes both single and double quoted content
397
+ cmd_without_quotes = command.gsub(/'[^']*'|"[^"]*"/, '')
398
+
270
399
  dangerous_patterns = [
271
400
  /eval\s*\(/,
272
401
  /exec\s*\(/,
273
402
  /system\s*\(/,
274
- /`.*`/,
275
- /\$\(.*\)/,
403
+ /`[^`]+`/, # Command substitution with backticks (but only if not in quotes)
404
+ /\$\([^)]+\)/, # Command substitution with $() (but only if not in quotes)
276
405
  /\|\s*sh\s*$/,
277
406
  /\|\s*bash\s*$/,
278
407
  />\s*\/etc\//,
@@ -281,7 +410,7 @@ module Clacky
281
410
  ]
282
411
 
283
412
  dangerous_patterns.each do |pattern|
284
- if command.match?(pattern)
413
+ if cmd_without_quotes.match?(pattern)
285
414
  raise SecurityError, "Dangerous command pattern detected: #{pattern.source}"
286
415
  end
287
416
  end
@@ -95,7 +95,7 @@ module Clacky
95
95
  if elapsed > soft_timeout && !soft_timeout_triggered
96
96
  soft_timeout_triggered = true
97
97
 
98
- # L1:
98
+ # L1: Check for interaction patterns
99
99
  interaction = detect_interaction(stdout_buffer.string)
100
100
  if interaction
101
101
  Process.kill('TERM', wait_thr.pid) rescue nil
@@ -107,24 +107,6 @@ module Clacky
107
107
  max_output_lines
108
108
  )
109
109
  end
110
-
111
- # L2:
112
- last_size = stdout_buffer.size
113
- stdin.puts("\n") rescue nil
114
- sleep 2
115
-
116
- if stdout_buffer.size > last_size
117
- next
118
- else
119
- Process.kill('TERM', wait_thr.pid) rescue nil
120
- return format_stuck_result(
121
- command,
122
- stdout_buffer.string,
123
- stderr_buffer.string,
124
- elapsed,
125
- max_output_lines
126
- )
127
- end
128
110
  end
129
111
 
130
112
  break unless wait_thr.alive?
@@ -255,36 +237,6 @@ module Clacky
255
237
  MSG
256
238
  end
257
239
 
258
- def format_stuck_result(command, stdout, stderr, elapsed, max_output_lines)
259
- {
260
- command: command,
261
- stdout: truncate_output(stdout, max_output_lines),
262
- stderr: truncate_output(stderr, max_output_lines),
263
- exit_code: -3,
264
- success: false,
265
- state: 'STUCK',
266
- elapsed: elapsed,
267
- message: format_stuck_message(truncate_output(stdout, max_output_lines), elapsed),
268
- output_truncated: output_truncated?(stdout, stderr, max_output_lines)
269
- }
270
- end
271
-
272
- def format_stuck_message(output, elapsed)
273
- <<~MSG
274
- #{output}
275
-
276
- #{'=' * 60}
277
- [Terminal State: STUCK]
278
- #{'=' * 60}
279
-
280
- The terminal is not responding after #{elapsed.round(1)}s.
281
-
282
- Suggested actions:
283
- • Try interrupting with Ctrl+C
284
- • Check if command is frozen
285
- MSG
286
- end
287
-
288
240
  def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
289
241
  {
290
242
  command: command,
@@ -332,10 +284,10 @@ module Clacky
332
284
 
333
285
  if exit_code == 0
334
286
  lines = stdout.lines.size
335
- " Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
287
+ "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
336
288
  else
337
289
  error_msg = stderr.lines.first&.strip || "Failed"
338
- "Exit #{exit_code}: #{error_msg[0..50]}"
290
+ "[Exit #{exit_code}] #{error_msg[0..50]}"
339
291
  end
340
292
  end
341
293
  end
@@ -29,12 +29,17 @@ module Clacky
29
29
  id: {
30
30
  type: "integer",
31
31
  description: "The task ID (required for 'complete' and 'remove' actions)"
32
+ },
33
+ ids: {
34
+ type: "array",
35
+ items: { type: "integer" },
36
+ description: "Array of task IDs for batch removal (for 'remove' action). Example: [1, 3, 5]"
32
37
  }
33
38
  },
34
39
  required: ["action"]
35
40
  }
36
41
 
37
- def execute(action:, task: nil, tasks: nil, id: nil, todos_storage: nil)
42
+ def execute(action:, task: nil, tasks: nil, id: nil, ids: nil, todos_storage: nil)
38
43
  # todos_storage is injected by Agent, stores todos in memory
39
44
  @todos = todos_storage || []
40
45
 
@@ -46,7 +51,12 @@ module Clacky
46
51
  when "complete"
47
52
  complete_todo(id)
48
53
  when "remove"
49
- remove_todo(id)
54
+ # Support both single ID and batch IDs
55
+ if ids && ids.is_a?(Array)
56
+ remove_todos(ids)
57
+ else
58
+ remove_todo(id)
59
+ end
50
60
  when "clear"
51
61
  clear_todos
52
62
  else
@@ -65,7 +75,12 @@ module Clacky
65
75
  when 'list'
66
76
  "TodoManager(list)"
67
77
  when 'remove'
68
- "TodoManager(remove ##{args[:id] || args['id']})"
78
+ ids = args[:ids] || args['ids']
79
+ if ids && ids.is_a?(Array) && !ids.empty?
80
+ "TodoManager(remove #{ids.size} tasks: #{ids.join(', ')})"
81
+ else
82
+ "TodoManager(remove ##{args[:id] || args['id']})"
83
+ end
69
84
  when 'clear'
70
85
  "TodoManager(clear all)"
71
86
  else
@@ -223,6 +238,38 @@ module Clacky
223
238
  cleared_count: count
224
239
  }
225
240
  end
241
+
242
+ def remove_todos(ids)
243
+ return { error: "Task IDs array is required" } if ids.nil? || ids.empty?
244
+
245
+ todos = load_todos
246
+ removed_todos = []
247
+ not_found_ids = []
248
+
249
+ ids.each do |id|
250
+ todo = todos.find { |t| t[:id] == id }
251
+ if todo
252
+ removed_todos << todo
253
+ else
254
+ not_found_ids << id
255
+ end
256
+ end
257
+
258
+ # Remove all found todos
259
+ todos.reject! { |t| ids.include?(t[:id]) }
260
+ save_todos(todos)
261
+
262
+ result = {
263
+ message: "#{removed_todos.size} task(s) removed",
264
+ removed: removed_todos,
265
+ remaining: todos.size
266
+ }
267
+
268
+ # Add warning about not found IDs
269
+ result[:not_found] = not_found_ids unless not_found_ids.empty?
270
+
271
+ result
272
+ end
226
273
  end
227
274
  end
228
275
  end
@@ -363,7 +363,7 @@ module Clacky
363
363
  when 'help'
364
364
  "❓ Help displayed"
365
365
  else
366
- success ? " #{action} completed" : " #{action} failed"
366
+ success ? "[OK] #{action} completed" : "[Error] #{action} failed"
367
367
  end
368
368
  end
369
369
  end
@@ -40,8 +40,8 @@ module Clacky
40
40
  # Fetch the web page
41
41
  response = fetch_url(uri)
42
42
 
43
- # Extract content
44
- content = response.body
43
+ # Extract content and force UTF-8 encoding at the source
44
+ content = response.body.force_encoding('UTF-8').scrub('?')
45
45
  content_type = response["content-type"] || ""
46
46
 
47
47
  # Parse HTML if it's an HTML page
@@ -149,11 +149,11 @@ module Clacky
149
149
 
150
150
  def format_result(result)
151
151
  if result[:error]
152
- " #{result[:error]}"
152
+ "[Error] #{result[:error]}"
153
153
  else
154
154
  title = result[:title] || 'Untitled'
155
155
  display_title = title.length > 40 ? "#{title[0..37]}..." : title
156
- " Fetched: #{display_title}"
156
+ "[OK] Fetched: #{display_title}"
157
157
  end
158
158
  end
159
159
  end
@@ -48,14 +48,14 @@ module Clacky
48
48
  end
49
49
  end
50
50
 
51
- def search_duckduckgo(query, max_results)
51
+ private def search_duckduckgo(query, max_results)
52
52
  # DuckDuckGo HTML search endpoint
53
53
  encoded_query = CGI.escape(query)
54
54
  url = URI("https://html.duckduckgo.com/html/?q=#{encoded_query}")
55
55
 
56
56
  # Make request with user agent
57
57
  request = Net::HTTP::Get.new(url)
58
- request["User-Agent"] = "Mozilla/5.0 (compatible; Clacky/1.0)"
58
+ request["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
59
59
 
60
60
  response = Net::HTTP.start(url.hostname, url.port, use_ssl: true, read_timeout: 10) do |http|
61
61
  http.request(request)
@@ -78,45 +78,57 @@ module Clacky
78
78
  ]
79
79
  end
80
80
 
81
- def parse_duckduckgo_html(html, max_results)
81
+ private def parse_duckduckgo_html(html, max_results)
82
82
  results = []
83
83
 
84
- # Simple regex-based parsing (not perfect but works for basic cases)
85
- # Look for result blocks in DuckDuckGo HTML
86
- html.scan(%r{<div class="result__body">.*?</div>}m).each do |block|
84
+ # Ensure HTML is UTF-8 encoded
85
+ html = html.force_encoding('UTF-8') unless html.encoding == Encoding::UTF_8
86
+
87
+ # Extract all result links and snippets
88
+ # Pattern: <a class="result__a" href="//duckduckgo.com/l/?uddg=ENCODED_URL...">TITLE</a>
89
+ links = html.scan(%r{<a[^>]*class="result__a"[^>]*href="//duckduckgo\.com/l/\?uddg=([^"&]+)[^"]*"[^>]*>(.*?)</a>}m)
90
+
91
+ # Pattern: <a class="result__snippet">SNIPPET</a>
92
+ snippets = html.scan(%r{<a[^>]*class="result__snippet"[^>]*>(.*?)</a>}m)
93
+
94
+ # Combine links and snippets
95
+ links.each_with_index do |link_data, index|
87
96
  break if results.length >= max_results
88
97
 
89
- # Extract title and URL
90
- if block =~ %r{<a.*?href="//duckduckgo\.com/l/\?uddg=([^"&]+).*?".*?>(.*?)</a>}m
91
- url = CGI.unescape($1)
92
- title = $2.gsub(/<[^>]+>/, "").strip
93
-
94
- # Extract snippet
95
- snippet = ""
96
- if block =~ %r{<a class="result__snippet".*?>(.*?)</a>}m
97
- snippet = $1.gsub(/<[^>]+>/, "").strip
98
- end
99
-
100
- results << {
101
- title: title,
102
- url: url,
103
- snippet: snippet
104
- }
98
+ url = CGI.unescape(link_data[0]).force_encoding('UTF-8')
99
+ title = link_data[1].gsub(/<[^>]+>/, "").strip
100
+ title = CGI.unescapeHTML(title) if title.include?("&")
101
+
102
+ snippet = ""
103
+ if snippets[index]
104
+ snippet = snippets[index][0].gsub(/<[^>]+>/, "").strip
105
+ snippet = CGI.unescapeHTML(snippet) if snippet.include?("&")
105
106
  end
107
+
108
+ results << {
109
+ title: title,
110
+ url: url,
111
+ snippet: snippet
112
+ }
106
113
  end
107
114
 
108
115
  # If parsing failed, provide a fallback
109
116
  if results.empty?
110
117
  results << {
111
118
  title: "Web search results",
112
- url: "https://duckduckgo.com/?q=#{CGI.escape(query)}",
113
- snippet: "Could not parse search results. Visit the URL to see results."
119
+ url: "https://duckduckgo.com/",
120
+ snippet: "Could not parse search results. Please try again."
114
121
  }
115
122
  end
116
123
 
117
124
  results
118
- rescue StandardError
119
- []
125
+ rescue StandardError => e
126
+ # Return fallback on error
127
+ [{
128
+ title: "Web search error",
129
+ url: "https://duckduckgo.com/",
130
+ snippet: "Error parsing results: #{e.message}"
131
+ }]
120
132
  end
121
133
 
122
134
  def format_call(args)
@@ -127,10 +139,10 @@ module Clacky
127
139
 
128
140
  def format_result(result)
129
141
  if result[:error]
130
- " #{result[:error]}"
142
+ "[Error] #{result[:error]}"
131
143
  else
132
144
  count = result[:count] || 0
133
- " Found #{count} results"
145
+ "[OK] Found #{count} results"
134
146
  end
135
147
  end
136
148
  end