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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +39 -88
- data/homebrew/README.md +96 -0
- data/homebrew/openclacky.rb +24 -0
- data/lib/clacky/agent.rb +557 -122
- data/lib/clacky/cli.rb +431 -3
- 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 +245 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +53 -17
- data/lib/clacky/tools/shell.rb +109 -5
- 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 +279 -141
- data/lib/clacky/ui2/layout_manager.rb +147 -67
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +3 -3
- data/lib/clacky/ui2/themes/minimal_theme.rb +3 -3
- data/lib/clacky/ui2/ui_controller.rb +80 -29
- data/lib/clacky/ui2.rb +0 -1
- data/lib/clacky/utils/arguments_parser.rb +7 -2
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/utils/file_processor.rb +201 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +249 -0
- data/scripts/uninstall.sh +146 -0
- metadata +10 -2
- data/lib/clacky/ui2/components/output_area.rb +0 -112
|
@@ -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
|
|
@@ -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
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
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
|