openclacky 0.5.4 → 0.5.6
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/.clackyrules +4 -0
- data/README.md +1 -1
- data/lib/clacky/agent.rb +190 -19
- data/lib/clacky/cli.rb +126 -141
- data/lib/clacky/client.rb +53 -7
- data/lib/clacky/model_pricing.rb +280 -0
- data/lib/clacky/progress_indicator.rb +1 -1
- data/lib/clacky/tools/file_reader.rb +73 -10
- data/lib/clacky/tools/grep.rb +74 -7
- data/lib/clacky/tools/safe_shell.rb +9 -4
- data/lib/clacky/tools/shell.rb +60 -22
- data/lib/clacky/ui/banner.rb +22 -11
- data/lib/clacky/ui/enhanced_prompt.rb +366 -223
- data/lib/clacky/ui/formatter.rb +1 -1
- data/lib/clacky/ui/statusbar.rb +0 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -1
- metadata +2 -2
- data/lib/clacky/conversation.rb +0 -41
|
@@ -9,7 +9,7 @@ require "base64"
|
|
|
9
9
|
module Clacky
|
|
10
10
|
module UI
|
|
11
11
|
# Enhanced input prompt with multi-line support and image paste
|
|
12
|
-
#
|
|
12
|
+
#
|
|
13
13
|
# Features:
|
|
14
14
|
# - Shift+Enter: Add new line
|
|
15
15
|
# - Enter: Submit message
|
|
@@ -20,6 +20,7 @@ module Clacky
|
|
|
20
20
|
|
|
21
21
|
def initialize
|
|
22
22
|
@pastel = Pastel.new
|
|
23
|
+
@formatter = Formatter.new
|
|
23
24
|
@images = [] # Array of image file paths
|
|
24
25
|
@paste_counter = 0 # Counter for paste operations
|
|
25
26
|
@paste_placeholders = {} # Map of placeholder text to actual pasted content
|
|
@@ -28,9 +29,13 @@ module Clacky
|
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
# Read user input with enhanced features
|
|
31
|
-
# @param prefix [String] Prompt prefix (default: "
|
|
32
|
-
# @
|
|
33
|
-
|
|
32
|
+
# @param prefix [String] Prompt prefix (default: "❯")
|
|
33
|
+
# @param block [Proc] Optional callback when Shift+Tab is pressed (receives display_lines)
|
|
34
|
+
# @return [Hash, nil] Returns:
|
|
35
|
+
# - { text: String, images: Array } for normal input
|
|
36
|
+
# - { command: Symbol } for commands (:clear, :exit)
|
|
37
|
+
# - nil on EOF
|
|
38
|
+
def read_input(prefix: "❯", &block)
|
|
34
39
|
@images = []
|
|
35
40
|
lines = []
|
|
36
41
|
cursor_pos = 0
|
|
@@ -38,8 +43,8 @@ module Clacky
|
|
|
38
43
|
@last_ctrl_c_time = nil # Track when Ctrl+C was last pressed
|
|
39
44
|
|
|
40
45
|
loop do
|
|
41
|
-
# Display the prompt
|
|
42
|
-
|
|
46
|
+
# Display the prompt (simplified version)
|
|
47
|
+
display_simple_prompt(lines, prefix, line_index, cursor_pos)
|
|
43
48
|
|
|
44
49
|
# Read a single character/key
|
|
45
50
|
begin
|
|
@@ -47,18 +52,18 @@ module Clacky
|
|
|
47
52
|
rescue Interrupt
|
|
48
53
|
return nil
|
|
49
54
|
end
|
|
50
|
-
|
|
55
|
+
|
|
51
56
|
# Handle buffered rapid input (system paste detection)
|
|
52
57
|
if key.is_a?(Hash) && key[:type] == :rapid_input
|
|
53
58
|
pasted_text = key[:text]
|
|
54
|
-
pasted_lines = pasted_text.split(
|
|
55
|
-
|
|
59
|
+
pasted_lines = pasted_text.split(/\r\n|\r|\n/)
|
|
60
|
+
|
|
56
61
|
if pasted_lines.size > 1
|
|
57
62
|
# Multi-line rapid input - use placeholder for display
|
|
58
63
|
@paste_counter += 1
|
|
59
64
|
placeholder = "[##{@paste_counter} Paste Text]"
|
|
60
65
|
@paste_placeholders[placeholder] = pasted_text
|
|
61
|
-
|
|
66
|
+
|
|
62
67
|
# Insert placeholder at cursor position
|
|
63
68
|
chars = (lines[line_index] || "").chars
|
|
64
69
|
placeholder_chars = placeholder.chars
|
|
@@ -91,9 +96,34 @@ module Clacky
|
|
|
91
96
|
cursor_pos = 0
|
|
92
97
|
|
|
93
98
|
when "\r" # Enter - submit
|
|
99
|
+
# Check if it's a command
|
|
100
|
+
input_text = lines.join("\n").strip
|
|
101
|
+
|
|
102
|
+
if input_text.start_with?('/')
|
|
103
|
+
clear_simple_prompt(lines.size)
|
|
104
|
+
|
|
105
|
+
# Parse command
|
|
106
|
+
case input_text
|
|
107
|
+
when '/clear'
|
|
108
|
+
@last_display_lines = 0 # Reset so CLI messages won't be cleared
|
|
109
|
+
return { command: :clear }
|
|
110
|
+
when '/exit', '/quit'
|
|
111
|
+
@last_display_lines = 0 # Reset before exit
|
|
112
|
+
return { command: :exit }
|
|
113
|
+
else
|
|
114
|
+
# Unknown command - show error and continue
|
|
115
|
+
@formatter.warning("Unknown command: #{input_text} (Available: /clear, /exit)")
|
|
116
|
+
@last_display_lines = 0 # Reset so next display won't clear these messages
|
|
117
|
+
lines = []
|
|
118
|
+
cursor_pos = 0
|
|
119
|
+
line_index = 0
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
94
124
|
# Submit if not empty
|
|
95
|
-
unless
|
|
96
|
-
|
|
125
|
+
unless input_text.empty? && @images.empty?
|
|
126
|
+
clear_simple_prompt(lines.size)
|
|
97
127
|
# Replace placeholders with actual pasted content
|
|
98
128
|
final_text = expand_placeholders(lines.join("\n"))
|
|
99
129
|
return { text: final_text, images: @images.dup }
|
|
@@ -102,15 +132,15 @@ module Clacky
|
|
|
102
132
|
when "\u0003" # Ctrl+C
|
|
103
133
|
# Check if input is empty
|
|
104
134
|
has_content = lines.any? { |line| !line.strip.empty? } || @images.any?
|
|
105
|
-
|
|
135
|
+
|
|
106
136
|
if has_content
|
|
107
137
|
# Input has content - clear it on first Ctrl+C
|
|
108
138
|
current_time = Time.now.to_f
|
|
109
139
|
time_since_last = @last_ctrl_c_time ? (current_time - @last_ctrl_c_time) : Float::INFINITY
|
|
110
|
-
|
|
140
|
+
|
|
111
141
|
if time_since_last < 2.0 # Within 2 seconds of last Ctrl+C
|
|
112
142
|
# Second Ctrl+C within 2 seconds - exit
|
|
113
|
-
|
|
143
|
+
clear_simple_prompt(lines.size)
|
|
114
144
|
return nil
|
|
115
145
|
else
|
|
116
146
|
# First Ctrl+C - clear content
|
|
@@ -124,26 +154,58 @@ module Clacky
|
|
|
124
154
|
end
|
|
125
155
|
else
|
|
126
156
|
# Input is empty - exit immediately
|
|
127
|
-
|
|
157
|
+
clear_simple_prompt(lines.size)
|
|
128
158
|
return nil
|
|
129
159
|
end
|
|
130
160
|
|
|
131
161
|
when "\u0016" # Ctrl+V - Paste
|
|
132
162
|
pasted = paste_from_clipboard
|
|
133
163
|
if pasted[:type] == :image
|
|
134
|
-
# Save image and add to list
|
|
135
|
-
@images
|
|
164
|
+
# Save image and add to list (max 3 images)
|
|
165
|
+
if @images.size < 3
|
|
166
|
+
@images << pasted[:path]
|
|
167
|
+
else
|
|
168
|
+
# Show warning below input box (without extra newline)
|
|
169
|
+
@formatter.warning("Maximum 3 images allowed. Delete an image first (Ctrl+D).")
|
|
170
|
+
|
|
171
|
+
# Wait a moment for user to see the message
|
|
172
|
+
sleep(1.5)
|
|
173
|
+
|
|
174
|
+
# Clear the warning line
|
|
175
|
+
print "\r\e[2K" # Clear current line
|
|
176
|
+
|
|
177
|
+
# Now clear the entire input box using the saved line count
|
|
178
|
+
if @last_display_lines && @last_display_lines > 0
|
|
179
|
+
# Move up to the first line of input box
|
|
180
|
+
(@last_display_lines - 1).times do
|
|
181
|
+
print "\e[1A"
|
|
182
|
+
end
|
|
183
|
+
# Clear all lines
|
|
184
|
+
@last_display_lines.times do |i|
|
|
185
|
+
print "\r\e[2K"
|
|
186
|
+
print "\e[1B" if i < @last_display_lines - 1
|
|
187
|
+
end
|
|
188
|
+
# Move back to the first line
|
|
189
|
+
(@last_display_lines - 1).times do
|
|
190
|
+
print "\e[1A"
|
|
191
|
+
end
|
|
192
|
+
print "\r"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Reset display state so next display will redraw
|
|
196
|
+
@last_display_lines = 0
|
|
197
|
+
end
|
|
136
198
|
else
|
|
137
199
|
# Handle pasted text
|
|
138
200
|
pasted_text = pasted[:text]
|
|
139
|
-
pasted_lines = pasted_text.split(
|
|
140
|
-
|
|
201
|
+
pasted_lines = pasted_text.split(/\r\n|\r|\n/)
|
|
202
|
+
|
|
141
203
|
if pasted_lines.size > 1
|
|
142
204
|
# Multi-line paste - use placeholder for display
|
|
143
205
|
@paste_counter += 1
|
|
144
206
|
placeholder = "[##{@paste_counter} Paste Text]"
|
|
145
207
|
@paste_placeholders[placeholder] = pasted_text
|
|
146
|
-
|
|
208
|
+
|
|
147
209
|
# Insert placeholder at cursor position
|
|
148
210
|
chars = (lines[line_index] || "").chars
|
|
149
211
|
placeholder_chars = placeholder.chars
|
|
@@ -196,12 +258,140 @@ module Clacky
|
|
|
196
258
|
when "\e[D" # Left arrow
|
|
197
259
|
cursor_pos = [cursor_pos - 1, 0].max
|
|
198
260
|
|
|
261
|
+
# Ignore Shift+Arrow keys (they produce sequences like \e[1;2A, \e[1;2B, etc.)
|
|
262
|
+
when /\e\[1;2[ABCD]/
|
|
263
|
+
# Do nothing - ignore Shift+Arrow keys
|
|
264
|
+
|
|
265
|
+
when "\e[Z" # Shift+Tab - Toggle auto-approve mode
|
|
266
|
+
# Call the block to update status bar if provided
|
|
267
|
+
if block
|
|
268
|
+
block.call(@last_display_lines)
|
|
269
|
+
end
|
|
270
|
+
# Continue the input loop, don't return
|
|
271
|
+
|
|
272
|
+
when "\u0001" # Ctrl+A - Move to beginning of line
|
|
273
|
+
cursor_pos = 0
|
|
274
|
+
|
|
275
|
+
when "\u0005" # Ctrl+E - Move to end of line
|
|
276
|
+
current_line = lines[line_index] || ""
|
|
277
|
+
cursor_pos = current_line.chars.length
|
|
278
|
+
|
|
279
|
+
when "\u0006" # Ctrl+F - Move forward one character
|
|
280
|
+
current_line = lines[line_index] || ""
|
|
281
|
+
cursor_pos = [cursor_pos + 1, current_line.chars.length].min
|
|
282
|
+
|
|
283
|
+
when "\u0002" # Ctrl+B - Move backward one character
|
|
284
|
+
cursor_pos = [cursor_pos - 1, 0].max
|
|
285
|
+
|
|
286
|
+
when "\u000B" # Ctrl+K - Delete from cursor to end of line
|
|
287
|
+
current_line = lines[line_index] || ""
|
|
288
|
+
chars = current_line.chars
|
|
289
|
+
lines[line_index] = chars[0...cursor_pos].join
|
|
290
|
+
|
|
291
|
+
when "\u0015" # Ctrl+U - Delete from beginning of line to cursor
|
|
292
|
+
current_line = lines[line_index] || ""
|
|
293
|
+
chars = current_line.chars
|
|
294
|
+
lines[line_index] = chars[cursor_pos..-1].join || ""
|
|
295
|
+
cursor_pos = 0
|
|
296
|
+
|
|
297
|
+
when "\u0017" # Ctrl+W - Delete previous word
|
|
298
|
+
current_line = lines[line_index] || ""
|
|
299
|
+
chars = current_line.chars
|
|
300
|
+
|
|
301
|
+
# Find the start of the previous word
|
|
302
|
+
pos = cursor_pos - 1
|
|
303
|
+
|
|
304
|
+
# Skip trailing whitespace
|
|
305
|
+
while pos >= 0 && chars[pos] =~ /\s/
|
|
306
|
+
pos -= 1
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Delete word characters
|
|
310
|
+
while pos >= 0 && chars[pos] =~ /\S/
|
|
311
|
+
pos -= 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Delete from pos+1 to cursor_pos
|
|
315
|
+
delete_start = pos + 1
|
|
316
|
+
chars.slice!(delete_start...cursor_pos)
|
|
317
|
+
lines[line_index] = chars.join
|
|
318
|
+
cursor_pos = delete_start
|
|
319
|
+
|
|
199
320
|
when "\u0004" # Ctrl+D - Delete image by number
|
|
200
321
|
if @images.any?
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
322
|
+
# If only one image, delete it directly
|
|
323
|
+
if @images.size == 1
|
|
324
|
+
@images.clear
|
|
325
|
+
|
|
326
|
+
# Clear the entire input box
|
|
327
|
+
if @last_display_lines && @last_display_lines > 0
|
|
328
|
+
# Move up to the first line of input box
|
|
329
|
+
(@last_display_lines - 1).times do
|
|
330
|
+
print "\e[1A"
|
|
331
|
+
end
|
|
332
|
+
# Clear all lines
|
|
333
|
+
@last_display_lines.times do |i|
|
|
334
|
+
print "\r\e[2K"
|
|
335
|
+
print "\e[1B" if i < @last_display_lines - 1
|
|
336
|
+
end
|
|
337
|
+
# Move back to the first line
|
|
338
|
+
(@last_display_lines - 1).times do
|
|
339
|
+
print "\e[1A"
|
|
340
|
+
end
|
|
341
|
+
print "\r"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Reset so next display starts fresh
|
|
345
|
+
@last_display_lines = 0
|
|
346
|
+
else
|
|
347
|
+
# Multiple images - ask which one to delete
|
|
348
|
+
# Move cursor to after the input box to show prompt
|
|
349
|
+
print "\n"
|
|
350
|
+
print "Delete image (1-#{@images.size}): "
|
|
351
|
+
$stdout.flush
|
|
352
|
+
|
|
353
|
+
# Read single character without waiting for Enter
|
|
354
|
+
deleted = false
|
|
355
|
+
$stdin.raw do |io|
|
|
356
|
+
char = io.getc
|
|
357
|
+
num = char.to_i
|
|
358
|
+
|
|
359
|
+
# Delete if valid number
|
|
360
|
+
if num > 0 && num <= @images.size
|
|
361
|
+
@images.delete_at(num - 1)
|
|
362
|
+
print "#{num} ✓"
|
|
363
|
+
deleted = true
|
|
364
|
+
else
|
|
365
|
+
print "✗"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Clear the prompt lines
|
|
370
|
+
print "\r\e[2K" # Clear current line
|
|
371
|
+
print "\e[1A" # Move up one line
|
|
372
|
+
print "\r\e[2K" # Clear the prompt line
|
|
373
|
+
|
|
374
|
+
# Now clear the entire input box using the saved line count
|
|
375
|
+
if @last_display_lines && @last_display_lines > 0
|
|
376
|
+
# We're now at the position where the input box ends
|
|
377
|
+
# Move up to the first line of input box
|
|
378
|
+
(@last_display_lines - 1).times do
|
|
379
|
+
print "\e[1A"
|
|
380
|
+
end
|
|
381
|
+
# Clear all lines
|
|
382
|
+
@last_display_lines.times do |i|
|
|
383
|
+
print "\r\e[2K"
|
|
384
|
+
print "\e[1B" if i < @last_display_lines - 1
|
|
385
|
+
end
|
|
386
|
+
# Move back to the first line
|
|
387
|
+
(@last_display_lines - 1).times do
|
|
388
|
+
print "\e[1A"
|
|
389
|
+
end
|
|
390
|
+
print "\r"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Reset so next display starts fresh
|
|
394
|
+
@last_display_lines = 0
|
|
205
395
|
end
|
|
206
396
|
end
|
|
207
397
|
|
|
@@ -210,7 +400,7 @@ module Clacky
|
|
|
210
400
|
if key.length >= 1 && key != "\e" && !key.start_with?("\e") && key.ord >= 32
|
|
211
401
|
lines[line_index] ||= ""
|
|
212
402
|
current_line = lines[line_index]
|
|
213
|
-
|
|
403
|
+
|
|
214
404
|
# Insert character at cursor position (using character index, not byte index)
|
|
215
405
|
chars = current_line.chars
|
|
216
406
|
chars.insert(cursor_pos, key)
|
|
@@ -226,203 +416,139 @@ module Clacky
|
|
|
226
416
|
|
|
227
417
|
private
|
|
228
418
|
|
|
229
|
-
#
|
|
230
|
-
def
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
result.gsub!(placeholder, actual_content)
|
|
234
|
-
end
|
|
235
|
-
result
|
|
236
|
-
end
|
|
419
|
+
# Display simplified prompt (just prefix and input, no box)
|
|
420
|
+
def display_simple_prompt(lines, prefix, line_index, cursor_pos)
|
|
421
|
+
# Hide terminal cursor (we render our own)
|
|
422
|
+
print "\e[?25l"
|
|
237
423
|
|
|
238
|
-
|
|
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)
|
|
424
|
+
lines_to_display = []
|
|
241
425
|
|
|
242
|
-
#
|
|
243
|
-
|
|
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
|
|
426
|
+
# Get terminal width for full-width separator
|
|
427
|
+
term_width = TTY::Screen.width
|
|
251
428
|
|
|
252
|
-
|
|
429
|
+
# Top separator line (full width)
|
|
430
|
+
lines_to_display << @pastel.dim("─" * term_width)
|
|
253
431
|
|
|
254
|
-
#
|
|
432
|
+
# Show images if any
|
|
255
433
|
if @images.any?
|
|
256
|
-
lines_to_display << @pastel.dim("╭─ Attached Images " + "─" * (width - 19) + "╮")
|
|
257
434
|
@images.each_with_index do |img_path, idx|
|
|
258
435
|
filename = File.basename(img_path)
|
|
259
|
-
# Check if file exists before getting size
|
|
260
436
|
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
lines_to_display << @pastel.dim("│ ") + display_content + @pastel.dim(" │")
|
|
437
|
+
line = @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize}) (Ctrl+D to delete)")
|
|
438
|
+
lines_to_display << line
|
|
264
439
|
end
|
|
265
|
-
lines_to_display << @pastel.dim("╰" + "─" * width + "╯")
|
|
266
|
-
lines_to_display << ""
|
|
267
440
|
end
|
|
268
441
|
|
|
269
|
-
# Display input
|
|
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
|
|
442
|
+
# Display input lines
|
|
278
443
|
display_lines = lines.empty? ? [""] : lines
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
444
|
+
|
|
445
|
+
display_lines.each_with_index do |line, idx|
|
|
446
|
+
if idx == 0
|
|
447
|
+
# First line with prefix
|
|
448
|
+
if idx == line_index
|
|
449
|
+
# Show cursor on this line
|
|
450
|
+
chars = line.chars
|
|
451
|
+
before_cursor = chars[0...cursor_pos].join
|
|
452
|
+
cursor_char = chars[cursor_pos] || " "
|
|
453
|
+
after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
|
|
454
|
+
|
|
455
|
+
line_display = "#{prefix} #{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
|
|
456
|
+
lines_to_display << line_display
|
|
457
|
+
else
|
|
458
|
+
lines_to_display << "#{prefix} #{line}"
|
|
459
|
+
end
|
|
292
460
|
else
|
|
293
|
-
#
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
461
|
+
# Continuation lines (indented to align with first line content)
|
|
462
|
+
indent = " " * (prefix.length + 1)
|
|
463
|
+
if idx == line_index
|
|
464
|
+
# Show cursor on this line
|
|
465
|
+
chars = line.chars
|
|
466
|
+
before_cursor = chars[0...cursor_pos].join
|
|
467
|
+
cursor_char = chars[cursor_pos] || " "
|
|
468
|
+
after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
|
|
469
|
+
|
|
470
|
+
line_display = "#{indent}#{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
|
|
471
|
+
lines_to_display << line_display
|
|
472
|
+
else
|
|
473
|
+
lines_to_display << "#{indent}#{line}"
|
|
303
474
|
end
|
|
304
475
|
end
|
|
305
476
|
end
|
|
306
|
-
|
|
307
|
-
#
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
477
|
+
|
|
478
|
+
# Bottom separator line (full width)
|
|
479
|
+
lines_to_display << @pastel.dim("─" * term_width)
|
|
480
|
+
|
|
481
|
+
# Different rendering strategy for first display vs updates
|
|
482
|
+
if @last_display_lines && @last_display_lines > 0
|
|
483
|
+
# Update mode: move to start and overwrite (no flicker)
|
|
484
|
+
# Move up to the first line (N-1 times since we're on line N)
|
|
485
|
+
(@last_display_lines - 1).times do
|
|
486
|
+
print "\e[1A" # Move up one line
|
|
348
487
|
end
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
488
|
+
print "\r" # Move to beginning of line
|
|
489
|
+
|
|
490
|
+
# Output lines by overwriting
|
|
491
|
+
lines_to_display.each_with_index do |line, idx|
|
|
492
|
+
print "\r\e[K" # Clear current line from cursor to end
|
|
493
|
+
print line
|
|
494
|
+
print "\n" if idx < lines_to_display.size - 1 # Newline except last line
|
|
364
495
|
end
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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(" │")
|
|
496
|
+
|
|
497
|
+
# If new display has fewer lines than old, clear the extra lines
|
|
498
|
+
if lines_to_display.size < @last_display_lines - 1
|
|
499
|
+
extra_lines = @last_display_lines - 1 - lines_to_display.size
|
|
500
|
+
extra_lines.times do
|
|
501
|
+
print "\n\r\e[K" # Move down and clear line
|
|
502
|
+
end
|
|
503
|
+
# Move back up to the last line of new display
|
|
504
|
+
extra_lines.times do
|
|
505
|
+
print "\e[1A"
|
|
506
|
+
end
|
|
392
507
|
end
|
|
508
|
+
|
|
509
|
+
print "\n" # Move cursor to next line
|
|
510
|
+
else
|
|
511
|
+
# First display: use simple newline approach
|
|
512
|
+
print lines_to_display.join("\n")
|
|
513
|
+
print "\n"
|
|
393
514
|
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
515
|
|
|
401
|
-
#
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
516
|
+
# Flush output to ensure it's displayed immediately
|
|
517
|
+
$stdout.flush
|
|
518
|
+
|
|
519
|
+
# Remember how many lines we displayed (including the newline)
|
|
520
|
+
@last_display_lines = lines_to_display.size + 1
|
|
414
521
|
end
|
|
415
522
|
|
|
416
|
-
# Clear prompt display
|
|
417
|
-
def
|
|
418
|
-
# Clear the prompt box we just displayed
|
|
523
|
+
# Clear simple prompt display
|
|
524
|
+
def clear_simple_prompt(num_lines)
|
|
419
525
|
if @last_display_lines && @last_display_lines > 0
|
|
420
|
-
|
|
526
|
+
# Move up to the first line (N-1 times since we're on line N)
|
|
527
|
+
(@last_display_lines - 1).times do
|
|
421
528
|
print "\e[1A" # Move up one line
|
|
422
|
-
|
|
529
|
+
end
|
|
530
|
+
# Now we're on the first line, clear all N lines
|
|
531
|
+
@last_display_lines.times do |i|
|
|
532
|
+
print "\r\e[2K" # Move to beginning and clear entire line
|
|
533
|
+
print "\e[1B" if i < @last_display_lines - 1 # Move down (except last line)
|
|
534
|
+
end
|
|
535
|
+
# Move back to the first line
|
|
536
|
+
(@last_display_lines - 1).times do
|
|
537
|
+
print "\e[1A"
|
|
423
538
|
end
|
|
424
539
|
print "\r" # Move to beginning of line
|
|
425
540
|
end
|
|
541
|
+
# Show terminal cursor again
|
|
542
|
+
print "\e[?25h"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# Expand placeholders to actual pasted content
|
|
546
|
+
def expand_placeholders(text)
|
|
547
|
+
result = text.dup
|
|
548
|
+
@paste_placeholders.each do |placeholder, actual_content|
|
|
549
|
+
result.gsub!(placeholder, actual_content)
|
|
550
|
+
end
|
|
551
|
+
result
|
|
426
552
|
end
|
|
427
553
|
|
|
428
554
|
# Read a single key press with escape sequence handling
|
|
@@ -430,74 +556,91 @@ module Clacky
|
|
|
430
556
|
# Also detects rapid input (paste-like behavior)
|
|
431
557
|
def read_key_with_rapid_detection
|
|
432
558
|
$stdin.set_encoding('UTF-8')
|
|
433
|
-
|
|
559
|
+
|
|
434
560
|
current_time = Time.now.to_f
|
|
435
561
|
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
436
562
|
@last_input_time = current_time
|
|
437
|
-
|
|
563
|
+
|
|
438
564
|
$stdin.raw do |io|
|
|
439
565
|
io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
|
|
440
566
|
c = io.getc
|
|
441
|
-
|
|
567
|
+
|
|
442
568
|
# Ensure character is UTF-8 encoded
|
|
443
569
|
c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
|
|
444
|
-
|
|
570
|
+
|
|
445
571
|
# Handle escape sequences (arrow keys, special keys)
|
|
446
572
|
if c == "\e"
|
|
447
|
-
# Read the next
|
|
573
|
+
# Read the next character to determine sequence type
|
|
448
574
|
begin
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
c = c +
|
|
575
|
+
next_char = io.read_nonblock(1)
|
|
576
|
+
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
577
|
+
c = c + next_char
|
|
578
|
+
|
|
579
|
+
# If it's a CSI sequence (starts with [)
|
|
580
|
+
if next_char == "["
|
|
581
|
+
# Read until we get a letter (the final character of CSI sequence)
|
|
582
|
+
# This handles both simple sequences like \e[A and complex ones like \e[1;2D
|
|
583
|
+
loop do
|
|
584
|
+
if IO.select([io], nil, nil, 0.01) # 10ms timeout
|
|
585
|
+
char = io.read_nonblock(1)
|
|
586
|
+
char = char.force_encoding('UTF-8') if char.encoding != Encoding::UTF_8
|
|
587
|
+
c = c + char
|
|
588
|
+
# Break if we got a letter (final character of CSI sequence)
|
|
589
|
+
break if char =~ /[A-Za-z~]/
|
|
590
|
+
else
|
|
591
|
+
break
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
452
595
|
rescue IO::WaitReadable, Errno::EAGAIN
|
|
453
596
|
# No more characters available
|
|
454
597
|
end
|
|
455
598
|
return c
|
|
456
599
|
end
|
|
457
|
-
|
|
600
|
+
|
|
458
601
|
# Check if there are more characters available using IO.select with timeout 0
|
|
459
602
|
has_more_input = IO.select([io], nil, nil, 0)
|
|
460
|
-
|
|
603
|
+
|
|
461
604
|
# If this is rapid input or there are more characters available
|
|
462
605
|
if is_rapid_input || has_more_input
|
|
463
606
|
# Buffer rapid input
|
|
464
607
|
buffer = c.to_s.dup
|
|
465
608
|
buffer.force_encoding('UTF-8')
|
|
466
|
-
|
|
609
|
+
|
|
467
610
|
# Keep reading available characters
|
|
468
611
|
loop do
|
|
469
612
|
begin
|
|
470
613
|
next_char = io.read_nonblock(1)
|
|
471
614
|
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
472
615
|
buffer << next_char
|
|
473
|
-
|
|
616
|
+
|
|
474
617
|
# Continue only if more characters are immediately available
|
|
475
618
|
break unless IO.select([io], nil, nil, 0)
|
|
476
619
|
rescue IO::WaitReadable, Errno::EAGAIN
|
|
477
620
|
break
|
|
478
621
|
end
|
|
479
622
|
end
|
|
480
|
-
|
|
623
|
+
|
|
481
624
|
# Ensure buffer is UTF-8
|
|
482
625
|
buffer.force_encoding('UTF-8')
|
|
483
|
-
|
|
626
|
+
|
|
484
627
|
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
485
628
|
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
486
629
|
# Remove any trailing \r or \n from rapid input buffer
|
|
487
630
|
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
488
631
|
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
489
632
|
end
|
|
490
|
-
|
|
633
|
+
|
|
491
634
|
# Single character rapid input, return as-is
|
|
492
635
|
return buffer[0] if buffer.length == 1
|
|
493
636
|
end
|
|
494
|
-
|
|
637
|
+
|
|
495
638
|
c
|
|
496
639
|
end
|
|
497
640
|
rescue Errno::EINTR
|
|
498
641
|
"\u0003" # Treat interrupt as Ctrl+C
|
|
499
642
|
end
|
|
500
|
-
|
|
643
|
+
|
|
501
644
|
# Legacy method for compatibility
|
|
502
645
|
def read_key
|
|
503
646
|
read_key_with_rapid_detection
|
|
@@ -522,17 +665,17 @@ module Clacky
|
|
|
522
665
|
def paste_from_clipboard_macos
|
|
523
666
|
require 'shellwords'
|
|
524
667
|
require 'fileutils'
|
|
525
|
-
|
|
668
|
+
|
|
526
669
|
# First check if there's an image in clipboard
|
|
527
670
|
# Use osascript to check clipboard content type
|
|
528
671
|
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
|
-
|
|
672
|
+
|
|
530
673
|
if has_image
|
|
531
674
|
# Create a persistent temporary file (won't be auto-deleted)
|
|
532
675
|
temp_dir = Dir.tmpdir
|
|
533
676
|
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
534
677
|
temp_path = File.join(temp_dir, temp_filename)
|
|
535
|
-
|
|
678
|
+
|
|
536
679
|
# Extract image using osascript
|
|
537
680
|
script = <<~APPLESCRIPT
|
|
538
681
|
set png_data to the clipboard as «class PNGf»
|
|
@@ -540,9 +683,9 @@ module Clacky
|
|
|
540
683
|
write png_data to the_file
|
|
541
684
|
close access the_file
|
|
542
685
|
APPLESCRIPT
|
|
543
|
-
|
|
686
|
+
|
|
544
687
|
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
545
|
-
|
|
688
|
+
|
|
546
689
|
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
547
690
|
return { type: :image, path: temp_path }
|
|
548
691
|
end
|
|
@@ -562,13 +705,13 @@ module Clacky
|
|
|
562
705
|
# Paste from Linux clipboard
|
|
563
706
|
def paste_from_clipboard_linux
|
|
564
707
|
require 'shellwords'
|
|
565
|
-
|
|
708
|
+
|
|
566
709
|
# Check if xclip is available
|
|
567
710
|
if system("which xclip >/dev/null 2>&1")
|
|
568
711
|
# Try to get image first
|
|
569
712
|
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
570
713
|
temp_file.close
|
|
571
|
-
|
|
714
|
+
|
|
572
715
|
# Try different image MIME types
|
|
573
716
|
["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
|
|
574
717
|
if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
|
|
@@ -577,7 +720,7 @@ module Clacky
|
|
|
577
720
|
end
|
|
578
721
|
end
|
|
579
722
|
end
|
|
580
|
-
|
|
723
|
+
|
|
581
724
|
# No image, get text - ensure UTF-8 encoding
|
|
582
725
|
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
583
726
|
text.force_encoding('UTF-8')
|
|
@@ -601,7 +744,7 @@ module Clacky
|
|
|
601
744
|
# Try to get image using PowerShell
|
|
602
745
|
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
603
746
|
temp_file.close
|
|
604
|
-
|
|
747
|
+
|
|
605
748
|
ps_script = <<~POWERSHELL
|
|
606
749
|
Add-Type -AssemblyName System.Windows.Forms
|
|
607
750
|
$img = [Windows.Forms.Clipboard]::GetImage()
|
|
@@ -612,9 +755,9 @@ module Clacky
|
|
|
612
755
|
exit 1
|
|
613
756
|
}
|
|
614
757
|
POWERSHELL
|
|
615
|
-
|
|
758
|
+
|
|
616
759
|
success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
|
|
617
|
-
|
|
760
|
+
|
|
618
761
|
if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
|
|
619
762
|
return { type: :image, path: temp_file.path }
|
|
620
763
|
end
|