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.
- checksums.yaml +4 -4
- data/lib/clacky/agent.rb +542 -54
- data/lib/clacky/cli.rb +341 -2
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +34 -43
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- metadata +5 -1
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
#
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
#
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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:
|
|
22
|
-
default:
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
151
|
+
# Check if we need to save to temp file
|
|
126
152
|
truncated = text.length > max_length
|
|
127
|
-
|
|
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
|