openclacky 0.5.2 → 0.5.4
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 +13 -0
- data/README.md +2 -0
- data/Rakefile +1 -5
- data/lib/clacky/agent.rb +29 -19
- data/lib/clacky/cli.rb +23 -9
- data/lib/clacky/gitignore_parser.rb +114 -0
- data/lib/clacky/progress_indicator.rb +1 -0
- data/lib/clacky/tools/grep.rb +245 -30
- data/lib/clacky/ui/enhanced_prompt.rb +643 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -1
- metadata +3 -2
- data/lib/clacky/ui/prompt.rb +0 -70
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require "tty-screen"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module UI
|
|
11
|
+
# Enhanced input prompt with multi-line support and image paste
|
|
12
|
+
#
|
|
13
|
+
# Features:
|
|
14
|
+
# - Shift+Enter: Add new line
|
|
15
|
+
# - Enter: Submit message
|
|
16
|
+
# - Ctrl+V: Paste text or images from clipboard
|
|
17
|
+
# - Image preview and management
|
|
18
|
+
class EnhancedPrompt
|
|
19
|
+
attr_reader :images
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@pastel = Pastel.new
|
|
23
|
+
@images = [] # Array of image file paths
|
|
24
|
+
@paste_counter = 0 # Counter for paste operations
|
|
25
|
+
@paste_placeholders = {} # Map of placeholder text to actual pasted content
|
|
26
|
+
@last_input_time = nil # Track last input time for rapid input detection
|
|
27
|
+
@rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Read user input with enhanced features
|
|
31
|
+
# @param prefix [String] Prompt prefix (default: "You:")
|
|
32
|
+
# @return [Hash, nil] { text: String, images: Array } or nil on EOF
|
|
33
|
+
def read_input(prefix: "You:")
|
|
34
|
+
@images = []
|
|
35
|
+
lines = []
|
|
36
|
+
cursor_pos = 0
|
|
37
|
+
line_index = 0
|
|
38
|
+
@last_ctrl_c_time = nil # Track when Ctrl+C was last pressed
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
# Display the prompt box
|
|
42
|
+
display_prompt_box(lines, prefix, line_index, cursor_pos)
|
|
43
|
+
|
|
44
|
+
# Read a single character/key
|
|
45
|
+
begin
|
|
46
|
+
key = read_key_with_rapid_detection
|
|
47
|
+
rescue Interrupt
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Handle buffered rapid input (system paste detection)
|
|
52
|
+
if key.is_a?(Hash) && key[:type] == :rapid_input
|
|
53
|
+
pasted_text = key[:text]
|
|
54
|
+
pasted_lines = pasted_text.split("\n")
|
|
55
|
+
|
|
56
|
+
if pasted_lines.size > 1
|
|
57
|
+
# Multi-line rapid input - use placeholder for display
|
|
58
|
+
@paste_counter += 1
|
|
59
|
+
placeholder = "[##{@paste_counter} Paste Text]"
|
|
60
|
+
@paste_placeholders[placeholder] = pasted_text
|
|
61
|
+
|
|
62
|
+
# Insert placeholder at cursor position
|
|
63
|
+
chars = (lines[line_index] || "").chars
|
|
64
|
+
placeholder_chars = placeholder.chars
|
|
65
|
+
chars.insert(cursor_pos, *placeholder_chars)
|
|
66
|
+
lines[line_index] = chars.join
|
|
67
|
+
cursor_pos += placeholder_chars.length
|
|
68
|
+
else
|
|
69
|
+
# Single line rapid input - insert at cursor (use chars for UTF-8)
|
|
70
|
+
chars = (lines[line_index] || "").chars
|
|
71
|
+
pasted_chars = pasted_text.chars
|
|
72
|
+
chars.insert(cursor_pos, *pasted_chars)
|
|
73
|
+
lines[line_index] = chars.join
|
|
74
|
+
cursor_pos += pasted_chars.length
|
|
75
|
+
end
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
case key
|
|
80
|
+
when "\n" # Shift+Enter - newline (Linux/Mac sends \n for Shift+Enter in some terminals)
|
|
81
|
+
# Add new line
|
|
82
|
+
if lines[line_index]
|
|
83
|
+
# Split current line at cursor (use chars for UTF-8)
|
|
84
|
+
chars = lines[line_index].chars
|
|
85
|
+
lines[line_index] = chars[0...cursor_pos].join
|
|
86
|
+
lines.insert(line_index + 1, chars[cursor_pos..-1].join || "")
|
|
87
|
+
else
|
|
88
|
+
lines.insert(line_index + 1, "")
|
|
89
|
+
end
|
|
90
|
+
line_index += 1
|
|
91
|
+
cursor_pos = 0
|
|
92
|
+
|
|
93
|
+
when "\r" # Enter - submit
|
|
94
|
+
# Submit if not empty
|
|
95
|
+
unless lines.join.strip.empty? && @images.empty?
|
|
96
|
+
clear_prompt_display(lines.size)
|
|
97
|
+
# Replace placeholders with actual pasted content
|
|
98
|
+
final_text = expand_placeholders(lines.join("\n"))
|
|
99
|
+
return { text: final_text, images: @images.dup }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
when "\u0003" # Ctrl+C
|
|
103
|
+
# Check if input is empty
|
|
104
|
+
has_content = lines.any? { |line| !line.strip.empty? } || @images.any?
|
|
105
|
+
|
|
106
|
+
if has_content
|
|
107
|
+
# Input has content - clear it on first Ctrl+C
|
|
108
|
+
current_time = Time.now.to_f
|
|
109
|
+
time_since_last = @last_ctrl_c_time ? (current_time - @last_ctrl_c_time) : Float::INFINITY
|
|
110
|
+
|
|
111
|
+
if time_since_last < 2.0 # Within 2 seconds of last Ctrl+C
|
|
112
|
+
# Second Ctrl+C within 2 seconds - exit
|
|
113
|
+
clear_prompt_display(lines.size)
|
|
114
|
+
return nil
|
|
115
|
+
else
|
|
116
|
+
# First Ctrl+C - clear content
|
|
117
|
+
@last_ctrl_c_time = current_time
|
|
118
|
+
lines = []
|
|
119
|
+
@images = []
|
|
120
|
+
cursor_pos = 0
|
|
121
|
+
line_index = 0
|
|
122
|
+
@paste_counter = 0
|
|
123
|
+
@paste_placeholders = {}
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
# Input is empty - exit immediately
|
|
127
|
+
clear_prompt_display(lines.size)
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
when "\u0016" # Ctrl+V - Paste
|
|
132
|
+
pasted = paste_from_clipboard
|
|
133
|
+
if pasted[:type] == :image
|
|
134
|
+
# Save image and add to list
|
|
135
|
+
@images << pasted[:path]
|
|
136
|
+
else
|
|
137
|
+
# Handle pasted text
|
|
138
|
+
pasted_text = pasted[:text]
|
|
139
|
+
pasted_lines = pasted_text.split("\n")
|
|
140
|
+
|
|
141
|
+
if pasted_lines.size > 1
|
|
142
|
+
# Multi-line paste - use placeholder for display
|
|
143
|
+
@paste_counter += 1
|
|
144
|
+
placeholder = "[##{@paste_counter} Paste Text]"
|
|
145
|
+
@paste_placeholders[placeholder] = pasted_text
|
|
146
|
+
|
|
147
|
+
# Insert placeholder at cursor position
|
|
148
|
+
chars = (lines[line_index] || "").chars
|
|
149
|
+
placeholder_chars = placeholder.chars
|
|
150
|
+
chars.insert(cursor_pos, *placeholder_chars)
|
|
151
|
+
lines[line_index] = chars.join
|
|
152
|
+
cursor_pos += placeholder_chars.length
|
|
153
|
+
else
|
|
154
|
+
# Single line paste - insert at cursor (use chars for UTF-8)
|
|
155
|
+
chars = (lines[line_index] || "").chars
|
|
156
|
+
pasted_chars = pasted_text.chars
|
|
157
|
+
chars.insert(cursor_pos, *pasted_chars)
|
|
158
|
+
lines[line_index] = chars.join
|
|
159
|
+
cursor_pos += pasted_chars.length
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
when "\u007F", "\b" # Backspace
|
|
164
|
+
if cursor_pos > 0
|
|
165
|
+
# Delete character before cursor (use chars for UTF-8)
|
|
166
|
+
chars = (lines[line_index] || "").chars
|
|
167
|
+
chars.delete_at(cursor_pos - 1)
|
|
168
|
+
lines[line_index] = chars.join
|
|
169
|
+
cursor_pos -= 1
|
|
170
|
+
elsif line_index > 0
|
|
171
|
+
# Join with previous line
|
|
172
|
+
prev_line = lines[line_index - 1]
|
|
173
|
+
current_line = lines[line_index]
|
|
174
|
+
lines.delete_at(line_index)
|
|
175
|
+
line_index -= 1
|
|
176
|
+
cursor_pos = prev_line.chars.length
|
|
177
|
+
lines[line_index] = prev_line + current_line
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
when "\e[A" # Up arrow
|
|
181
|
+
if line_index > 0
|
|
182
|
+
line_index -= 1
|
|
183
|
+
cursor_pos = [cursor_pos, (lines[line_index] || "").chars.length].min
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
when "\e[B" # Down arrow
|
|
187
|
+
if line_index < lines.size - 1
|
|
188
|
+
line_index += 1
|
|
189
|
+
cursor_pos = [cursor_pos, (lines[line_index] || "").chars.length].min
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
when "\e[C" # Right arrow
|
|
193
|
+
current_line = lines[line_index] || ""
|
|
194
|
+
cursor_pos = [cursor_pos + 1, current_line.chars.length].min
|
|
195
|
+
|
|
196
|
+
when "\e[D" # Left arrow
|
|
197
|
+
cursor_pos = [cursor_pos - 1, 0].max
|
|
198
|
+
|
|
199
|
+
when "\u0004" # Ctrl+D - Delete image by number
|
|
200
|
+
if @images.any?
|
|
201
|
+
print "\nEnter image number to delete (1-#{@images.size}): "
|
|
202
|
+
num = STDIN.gets.to_i
|
|
203
|
+
if num > 0 && num <= @images.size
|
|
204
|
+
@images.delete_at(num - 1)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
else
|
|
209
|
+
# Regular character input - support UTF-8
|
|
210
|
+
if key.length >= 1 && key != "\e" && !key.start_with?("\e") && key.ord >= 32
|
|
211
|
+
lines[line_index] ||= ""
|
|
212
|
+
current_line = lines[line_index]
|
|
213
|
+
|
|
214
|
+
# Insert character at cursor position (using character index, not byte index)
|
|
215
|
+
chars = current_line.chars
|
|
216
|
+
chars.insert(cursor_pos, key)
|
|
217
|
+
lines[line_index] = chars.join
|
|
218
|
+
cursor_pos += 1
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Ensure we have at least one line
|
|
223
|
+
lines << "" if lines.empty?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
# Expand placeholders to actual pasted content
|
|
230
|
+
def expand_placeholders(text)
|
|
231
|
+
result = text.dup
|
|
232
|
+
@paste_placeholders.each do |placeholder, actual_content|
|
|
233
|
+
result.gsub!(placeholder, actual_content)
|
|
234
|
+
end
|
|
235
|
+
result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Display the prompt box with images and input
|
|
239
|
+
def display_prompt_box(lines, prefix, line_index, cursor_pos)
|
|
240
|
+
width = TTY::Screen.width - 4 # Use full terminal width (minus 4 for borders)
|
|
241
|
+
|
|
242
|
+
# Clear previous display if exists
|
|
243
|
+
if @last_display_lines && @last_display_lines > 0
|
|
244
|
+
# Move cursor up and clear each line
|
|
245
|
+
@last_display_lines.times do
|
|
246
|
+
print "\e[1A" # Move up one line
|
|
247
|
+
print "\e[2K" # Clear entire line
|
|
248
|
+
end
|
|
249
|
+
print "\r" # Move to beginning of line
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
lines_to_display = []
|
|
253
|
+
|
|
254
|
+
# Display images if any
|
|
255
|
+
if @images.any?
|
|
256
|
+
lines_to_display << @pastel.dim("╭─ Attached Images " + "─" * (width - 19) + "╮")
|
|
257
|
+
@images.each_with_index do |img_path, idx|
|
|
258
|
+
filename = File.basename(img_path)
|
|
259
|
+
# Check if file exists before getting size
|
|
260
|
+
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
261
|
+
line_content = " #{idx + 1}. #{filename} (#{filesize})"
|
|
262
|
+
display_content = line_content.ljust(width - 2)
|
|
263
|
+
lines_to_display << @pastel.dim("│ ") + display_content + @pastel.dim(" │")
|
|
264
|
+
end
|
|
265
|
+
lines_to_display << @pastel.dim("╰" + "─" * width + "╯")
|
|
266
|
+
lines_to_display << ""
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Display input box
|
|
270
|
+
hint = "Shift+Enter:newline | Enter:submit | Ctrl+C:cancel"
|
|
271
|
+
lines_to_display << @pastel.dim("╭─ Message " + "─" * (width - 10) + "╮")
|
|
272
|
+
hint_line = @pastel.dim(hint)
|
|
273
|
+
padding = " " * [(width - hint.length - 2), 0].max
|
|
274
|
+
lines_to_display << @pastel.dim("│ ") + hint_line + padding + @pastel.dim(" │")
|
|
275
|
+
lines_to_display << @pastel.dim("├" + "─" * width + "┤")
|
|
276
|
+
|
|
277
|
+
# Display input lines with word wrap
|
|
278
|
+
display_lines = lines.empty? ? [""] : lines
|
|
279
|
+
max_display_lines = 15 # Show up to 15 wrapped lines
|
|
280
|
+
|
|
281
|
+
# Flatten all lines with word wrap
|
|
282
|
+
wrapped_display_lines = []
|
|
283
|
+
line_to_wrapped_mapping = [] # Track which original line each wrapped line belongs to
|
|
284
|
+
|
|
285
|
+
display_lines.each_with_index do |line, original_idx|
|
|
286
|
+
line_chars = line.chars
|
|
287
|
+
content_width = width - 2 # Available width for content (excluding borders)
|
|
288
|
+
|
|
289
|
+
if line_chars.length <= content_width
|
|
290
|
+
# Line fits in one display line
|
|
291
|
+
wrapped_display_lines << { text: line, original_line: original_idx, start_pos: 0 }
|
|
292
|
+
else
|
|
293
|
+
# Line needs wrapping
|
|
294
|
+
start_pos = 0
|
|
295
|
+
while start_pos < line_chars.length
|
|
296
|
+
chunk_chars = line_chars[start_pos...[start_pos + content_width, line_chars.length].min]
|
|
297
|
+
wrapped_display_lines << {
|
|
298
|
+
text: chunk_chars.join,
|
|
299
|
+
original_line: original_idx,
|
|
300
|
+
start_pos: start_pos
|
|
301
|
+
}
|
|
302
|
+
start_pos += content_width
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Find which wrapped line contains the cursor
|
|
308
|
+
cursor_wrapped_line_idx = 0
|
|
309
|
+
cursor_in_wrapped_pos = cursor_pos
|
|
310
|
+
content_width = width - 2
|
|
311
|
+
|
|
312
|
+
# Find all wrapped lines for the current line_index
|
|
313
|
+
current_line_wrapped = wrapped_display_lines.select.with_index { |wl, idx| wl[:original_line] == line_index }
|
|
314
|
+
|
|
315
|
+
if current_line_wrapped.any?
|
|
316
|
+
# Iterate through wrapped lines to find where cursor belongs
|
|
317
|
+
accumulated_chars = 0
|
|
318
|
+
found = false
|
|
319
|
+
|
|
320
|
+
current_line_wrapped.each_with_index do |wrapped_line, local_idx|
|
|
321
|
+
line_start = wrapped_line[:start_pos]
|
|
322
|
+
line_length = wrapped_line[:text].chars.length
|
|
323
|
+
line_end = line_start + line_length
|
|
324
|
+
|
|
325
|
+
# Find global index of this wrapped line
|
|
326
|
+
global_idx = wrapped_display_lines.index { |wl| wl == wrapped_line }
|
|
327
|
+
|
|
328
|
+
if cursor_pos >= line_start && cursor_pos < line_end
|
|
329
|
+
# Cursor is within this wrapped line
|
|
330
|
+
cursor_wrapped_line_idx = global_idx
|
|
331
|
+
cursor_in_wrapped_pos = cursor_pos - line_start
|
|
332
|
+
found = true
|
|
333
|
+
break
|
|
334
|
+
elsif cursor_pos == line_end && local_idx == current_line_wrapped.length - 1
|
|
335
|
+
# Cursor is at the very end of the last wrapped line for this line_index
|
|
336
|
+
cursor_wrapped_line_idx = global_idx
|
|
337
|
+
cursor_in_wrapped_pos = line_length
|
|
338
|
+
found = true
|
|
339
|
+
break
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Fallback: if not found, place cursor at the end of the last wrapped line
|
|
344
|
+
unless found
|
|
345
|
+
last_wrapped = current_line_wrapped.last
|
|
346
|
+
cursor_wrapped_line_idx = wrapped_display_lines.index { |wl| wl == last_wrapped }
|
|
347
|
+
cursor_in_wrapped_pos = last_wrapped[:text].chars.length
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Determine which wrapped lines to display (centered around cursor)
|
|
352
|
+
if wrapped_display_lines.size <= max_display_lines
|
|
353
|
+
display_start = 0
|
|
354
|
+
display_end = wrapped_display_lines.size - 1
|
|
355
|
+
else
|
|
356
|
+
# Center view around cursor line
|
|
357
|
+
half_display = max_display_lines / 2
|
|
358
|
+
display_start = [cursor_wrapped_line_idx - half_display, 0].max
|
|
359
|
+
display_end = [display_start + max_display_lines - 1, wrapped_display_lines.size - 1].min
|
|
360
|
+
|
|
361
|
+
# Adjust if we're near the end
|
|
362
|
+
if display_end - display_start < max_display_lines - 1
|
|
363
|
+
display_start = [display_end - max_display_lines + 1, 0].max
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Display the wrapped lines
|
|
368
|
+
(display_start..display_end).each do |idx|
|
|
369
|
+
wrapped_line = wrapped_display_lines[idx]
|
|
370
|
+
line_text = wrapped_line[:text]
|
|
371
|
+
line_chars = line_text.chars
|
|
372
|
+
content_width = width - 2
|
|
373
|
+
|
|
374
|
+
# Pad to full width
|
|
375
|
+
display_line = line_text.ljust(content_width)
|
|
376
|
+
|
|
377
|
+
if idx == cursor_wrapped_line_idx
|
|
378
|
+
# Show cursor on this wrapped line
|
|
379
|
+
before_cursor = line_chars[0...cursor_in_wrapped_pos].join
|
|
380
|
+
cursor_char = line_chars[cursor_in_wrapped_pos] || " "
|
|
381
|
+
after_cursor_chars = line_chars[(cursor_in_wrapped_pos + 1)..-1]
|
|
382
|
+
after_cursor = after_cursor_chars ? after_cursor_chars.join : ""
|
|
383
|
+
|
|
384
|
+
# Calculate padding
|
|
385
|
+
content_length = before_cursor.length + 1 + after_cursor.length
|
|
386
|
+
padding = " " * [content_width - content_length, 0].max
|
|
387
|
+
|
|
388
|
+
line_display = before_cursor + @pastel.on_white(@pastel.black(cursor_char)) + after_cursor + padding
|
|
389
|
+
lines_to_display << @pastel.dim("│ ") + line_display + @pastel.dim(" │")
|
|
390
|
+
else
|
|
391
|
+
lines_to_display << @pastel.dim("│ ") + display_line + @pastel.dim(" │")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Show scroll indicator if needed
|
|
396
|
+
if wrapped_display_lines.size > max_display_lines
|
|
397
|
+
scroll_info = " (#{display_start + 1}-#{display_end + 1}/#{wrapped_display_lines.size} lines) "
|
|
398
|
+
lines_to_display << @pastel.dim("│#{scroll_info.center(width)}│")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Footer - calculate width properly
|
|
402
|
+
footer_text = "Line #{line_index + 1}/#{display_lines.size} | Char #{cursor_pos}/#{(display_lines[line_index] || "").chars.length}"
|
|
403
|
+
# Total width = "╰─ " (3) + footer_text + " ─...─╯" (width - 3 - footer_text.length)
|
|
404
|
+
remaining_width = width - footer_text.length - 3 # 3 = "╰─ " length
|
|
405
|
+
footer_line = @pastel.dim("╰─ ") + @pastel.dim(footer_text) + @pastel.dim(" ") + @pastel.dim("─" * [remaining_width - 1, 0].max) + @pastel.dim("╯")
|
|
406
|
+
lines_to_display << footer_line
|
|
407
|
+
|
|
408
|
+
# Output all lines at once (use print to avoid extra newline at the end)
|
|
409
|
+
print lines_to_display.join("\n")
|
|
410
|
+
print "\n" # Add one controlled newline
|
|
411
|
+
|
|
412
|
+
# Remember how many lines we displayed
|
|
413
|
+
@last_display_lines = lines_to_display.size
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Clear prompt display after submission
|
|
417
|
+
def clear_prompt_display(num_lines)
|
|
418
|
+
# Clear the prompt box we just displayed
|
|
419
|
+
if @last_display_lines && @last_display_lines > 0
|
|
420
|
+
@last_display_lines.times do
|
|
421
|
+
print "\e[1A" # Move up one line
|
|
422
|
+
print "\e[2K" # Clear entire line
|
|
423
|
+
end
|
|
424
|
+
print "\r" # Move to beginning of line
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Read a single key press with escape sequence handling
|
|
429
|
+
# Handles UTF-8 multi-byte characters correctly
|
|
430
|
+
# Also detects rapid input (paste-like behavior)
|
|
431
|
+
def read_key_with_rapid_detection
|
|
432
|
+
$stdin.set_encoding('UTF-8')
|
|
433
|
+
|
|
434
|
+
current_time = Time.now.to_f
|
|
435
|
+
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
436
|
+
@last_input_time = current_time
|
|
437
|
+
|
|
438
|
+
$stdin.raw do |io|
|
|
439
|
+
io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
|
|
440
|
+
c = io.getc
|
|
441
|
+
|
|
442
|
+
# Ensure character is UTF-8 encoded
|
|
443
|
+
c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
|
|
444
|
+
|
|
445
|
+
# Handle escape sequences (arrow keys, special keys)
|
|
446
|
+
if c == "\e"
|
|
447
|
+
# Read the next 2 characters for escape sequences
|
|
448
|
+
begin
|
|
449
|
+
extra = io.read_nonblock(2)
|
|
450
|
+
extra = extra.force_encoding('UTF-8') if extra.encoding != Encoding::UTF_8
|
|
451
|
+
c = c + extra
|
|
452
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
|
453
|
+
# No more characters available
|
|
454
|
+
end
|
|
455
|
+
return c
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Check if there are more characters available using IO.select with timeout 0
|
|
459
|
+
has_more_input = IO.select([io], nil, nil, 0)
|
|
460
|
+
|
|
461
|
+
# If this is rapid input or there are more characters available
|
|
462
|
+
if is_rapid_input || has_more_input
|
|
463
|
+
# Buffer rapid input
|
|
464
|
+
buffer = c.to_s.dup
|
|
465
|
+
buffer.force_encoding('UTF-8')
|
|
466
|
+
|
|
467
|
+
# Keep reading available characters
|
|
468
|
+
loop do
|
|
469
|
+
begin
|
|
470
|
+
next_char = io.read_nonblock(1)
|
|
471
|
+
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
472
|
+
buffer << next_char
|
|
473
|
+
|
|
474
|
+
# Continue only if more characters are immediately available
|
|
475
|
+
break unless IO.select([io], nil, nil, 0)
|
|
476
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
|
477
|
+
break
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Ensure buffer is UTF-8
|
|
482
|
+
buffer.force_encoding('UTF-8')
|
|
483
|
+
|
|
484
|
+
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
485
|
+
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
486
|
+
# Remove any trailing \r or \n from rapid input buffer
|
|
487
|
+
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
488
|
+
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Single character rapid input, return as-is
|
|
492
|
+
return buffer[0] if buffer.length == 1
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
c
|
|
496
|
+
end
|
|
497
|
+
rescue Errno::EINTR
|
|
498
|
+
"\u0003" # Treat interrupt as Ctrl+C
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Legacy method for compatibility
|
|
502
|
+
def read_key
|
|
503
|
+
read_key_with_rapid_detection
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Paste from clipboard (cross-platform)
|
|
507
|
+
# @return [Hash] { type: :text/:image, text: String, path: String }
|
|
508
|
+
def paste_from_clipboard
|
|
509
|
+
case RbConfig::CONFIG["host_os"]
|
|
510
|
+
when /darwin/i
|
|
511
|
+
paste_from_clipboard_macos
|
|
512
|
+
when /linux/i
|
|
513
|
+
paste_from_clipboard_linux
|
|
514
|
+
when /mswin|mingw|cygwin/i
|
|
515
|
+
paste_from_clipboard_windows
|
|
516
|
+
else
|
|
517
|
+
{ type: :text, text: "" }
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Paste from macOS clipboard
|
|
522
|
+
def paste_from_clipboard_macos
|
|
523
|
+
require 'shellwords'
|
|
524
|
+
require 'fileutils'
|
|
525
|
+
|
|
526
|
+
# First check if there's an image in clipboard
|
|
527
|
+
# Use osascript to check clipboard content type
|
|
528
|
+
has_image = system("osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'on error' -e 'return false' -e 'end try' >/dev/null 2>&1")
|
|
529
|
+
|
|
530
|
+
if has_image
|
|
531
|
+
# Create a persistent temporary file (won't be auto-deleted)
|
|
532
|
+
temp_dir = Dir.tmpdir
|
|
533
|
+
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
534
|
+
temp_path = File.join(temp_dir, temp_filename)
|
|
535
|
+
|
|
536
|
+
# Extract image using osascript
|
|
537
|
+
script = <<~APPLESCRIPT
|
|
538
|
+
set png_data to the clipboard as «class PNGf»
|
|
539
|
+
set the_file to open for access POSIX file "#{temp_path}" with write permission
|
|
540
|
+
write png_data to the_file
|
|
541
|
+
close access the_file
|
|
542
|
+
APPLESCRIPT
|
|
543
|
+
|
|
544
|
+
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
545
|
+
|
|
546
|
+
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
547
|
+
return { type: :image, path: temp_path }
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# No image, try text - ensure UTF-8 encoding
|
|
552
|
+
text = `pbpaste 2>/dev/null`.to_s
|
|
553
|
+
text.force_encoding('UTF-8')
|
|
554
|
+
# Replace invalid UTF-8 sequences with replacement character
|
|
555
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
556
|
+
{ type: :text, text: text }
|
|
557
|
+
rescue => e
|
|
558
|
+
# Fallback to empty text on error
|
|
559
|
+
{ type: :text, text: "" }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Paste from Linux clipboard
|
|
563
|
+
def paste_from_clipboard_linux
|
|
564
|
+
require 'shellwords'
|
|
565
|
+
|
|
566
|
+
# Check if xclip is available
|
|
567
|
+
if system("which xclip >/dev/null 2>&1")
|
|
568
|
+
# Try to get image first
|
|
569
|
+
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
570
|
+
temp_file.close
|
|
571
|
+
|
|
572
|
+
# Try different image MIME types
|
|
573
|
+
["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
|
|
574
|
+
if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
|
|
575
|
+
if File.size(temp_file.path) > 0
|
|
576
|
+
return { type: :image, path: temp_file.path }
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# No image, get text - ensure UTF-8 encoding
|
|
582
|
+
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
583
|
+
text.force_encoding('UTF-8')
|
|
584
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
585
|
+
{ type: :text, text: text }
|
|
586
|
+
elsif system("which xsel >/dev/null 2>&1")
|
|
587
|
+
# Fallback to xsel for text only
|
|
588
|
+
text = `xsel --clipboard --output 2>/dev/null`.to_s
|
|
589
|
+
text.force_encoding('UTF-8')
|
|
590
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
591
|
+
{ type: :text, text: text }
|
|
592
|
+
else
|
|
593
|
+
{ type: :text, text: "" }
|
|
594
|
+
end
|
|
595
|
+
rescue => e
|
|
596
|
+
{ type: :text, text: "" }
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Paste from Windows clipboard
|
|
600
|
+
def paste_from_clipboard_windows
|
|
601
|
+
# Try to get image using PowerShell
|
|
602
|
+
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
603
|
+
temp_file.close
|
|
604
|
+
|
|
605
|
+
ps_script = <<~POWERSHELL
|
|
606
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
607
|
+
$img = [Windows.Forms.Clipboard]::GetImage()
|
|
608
|
+
if ($img) {
|
|
609
|
+
$img.Save('#{temp_file.path.gsub("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
610
|
+
exit 0
|
|
611
|
+
} else {
|
|
612
|
+
exit 1
|
|
613
|
+
}
|
|
614
|
+
POWERSHELL
|
|
615
|
+
|
|
616
|
+
success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
|
|
617
|
+
|
|
618
|
+
if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
|
|
619
|
+
return { type: :image, path: temp_file.path }
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# No image, get text - ensure UTF-8 encoding
|
|
623
|
+
text = `powershell -NoProfile -Command "Get-Clipboard" 2>nul`.to_s
|
|
624
|
+
text.force_encoding('UTF-8')
|
|
625
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
626
|
+
{ type: :text, text: text }
|
|
627
|
+
rescue => e
|
|
628
|
+
{ type: :text, text: "" }
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Format file size for display
|
|
632
|
+
def format_filesize(size)
|
|
633
|
+
if size < 1024
|
|
634
|
+
"#{size}B"
|
|
635
|
+
elsif size < 1024 * 1024
|
|
636
|
+
"#{(size / 1024.0).round(1)}KB"
|
|
637
|
+
else
|
|
638
|
+
"#{(size / 1024.0 / 1024.0).round(1)}MB"
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "clacky/tool_registry"
|
|
|
12
12
|
require_relative "clacky/thinking_verbs"
|
|
13
13
|
require_relative "clacky/progress_indicator"
|
|
14
14
|
require_relative "clacky/session_manager"
|
|
15
|
+
require_relative "clacky/gitignore_parser"
|
|
15
16
|
require_relative "clacky/utils/limit_stack"
|
|
16
17
|
require_relative "clacky/utils/path_helper"
|
|
17
18
|
require_relative "clacky/tools/base"
|
|
@@ -32,7 +33,7 @@ require_relative "clacky/agent"
|
|
|
32
33
|
|
|
33
34
|
# UI components
|
|
34
35
|
require_relative "clacky/ui/banner"
|
|
35
|
-
require_relative "clacky/ui/
|
|
36
|
+
require_relative "clacky/ui/enhanced_prompt"
|
|
36
37
|
require_relative "clacky/ui/statusbar"
|
|
37
38
|
require_relative "clacky/ui/formatter"
|
|
38
39
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openclacky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.5.
|
|
4
|
+
version: 0.5.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/clacky/client.rb
|
|
138
138
|
- lib/clacky/config.rb
|
|
139
139
|
- lib/clacky/conversation.rb
|
|
140
|
+
- lib/clacky/gitignore_parser.rb
|
|
140
141
|
- lib/clacky/hook_manager.rb
|
|
141
142
|
- lib/clacky/progress_indicator.rb
|
|
142
143
|
- lib/clacky/session_manager.rb
|
|
@@ -157,8 +158,8 @@ files:
|
|
|
157
158
|
- lib/clacky/tools/write.rb
|
|
158
159
|
- lib/clacky/trash_directory.rb
|
|
159
160
|
- lib/clacky/ui/banner.rb
|
|
161
|
+
- lib/clacky/ui/enhanced_prompt.rb
|
|
160
162
|
- lib/clacky/ui/formatter.rb
|
|
161
|
-
- lib/clacky/ui/prompt.rb
|
|
162
163
|
- lib/clacky/ui/statusbar.rb
|
|
163
164
|
- lib/clacky/utils/arguments_parser.rb
|
|
164
165
|
- lib/clacky/utils/limit_stack.rb
|