openclacky 0.5.6 → 0.6.0
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 +43 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +245 -340
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +156 -397
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +4 -120
- data/lib/clacky/tools/run_project.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +49 -13
- data/lib/clacky/tools/shell.rb +1 -49
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +38 -26
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +89 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1029 -0
- data/lib/clacky/ui2/components/message_component.rb +76 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +137 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
- data/lib/clacky/ui2/layout_manager.rb +331 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/screen_buffer.rb +238 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +99 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
- data/lib/clacky/ui2/ui_controller.rb +720 -0
- data/lib/clacky/ui2/view_renderer.rb +160 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +38 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
|
@@ -1,786 +0,0 @@
|
|
|
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
|
-
@formatter = Formatter.new
|
|
24
|
-
@images = [] # Array of image file paths
|
|
25
|
-
@paste_counter = 0 # Counter for paste operations
|
|
26
|
-
@paste_placeholders = {} # Map of placeholder text to actual pasted content
|
|
27
|
-
@last_input_time = nil # Track last input time for rapid input detection
|
|
28
|
-
@rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Read user input with enhanced features
|
|
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)
|
|
39
|
-
@images = []
|
|
40
|
-
lines = []
|
|
41
|
-
cursor_pos = 0
|
|
42
|
-
line_index = 0
|
|
43
|
-
@last_ctrl_c_time = nil # Track when Ctrl+C was last pressed
|
|
44
|
-
|
|
45
|
-
loop do
|
|
46
|
-
# Display the prompt (simplified version)
|
|
47
|
-
display_simple_prompt(lines, prefix, line_index, cursor_pos)
|
|
48
|
-
|
|
49
|
-
# Read a single character/key
|
|
50
|
-
begin
|
|
51
|
-
key = read_key_with_rapid_detection
|
|
52
|
-
rescue Interrupt
|
|
53
|
-
return nil
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Handle buffered rapid input (system paste detection)
|
|
57
|
-
if key.is_a?(Hash) && key[:type] == :rapid_input
|
|
58
|
-
pasted_text = key[:text]
|
|
59
|
-
pasted_lines = pasted_text.split(/\r\n|\r|\n/)
|
|
60
|
-
|
|
61
|
-
if pasted_lines.size > 1
|
|
62
|
-
# Multi-line rapid input - use placeholder for display
|
|
63
|
-
@paste_counter += 1
|
|
64
|
-
placeholder = "[##{@paste_counter} Paste Text]"
|
|
65
|
-
@paste_placeholders[placeholder] = pasted_text
|
|
66
|
-
|
|
67
|
-
# Insert placeholder at cursor position
|
|
68
|
-
chars = (lines[line_index] || "").chars
|
|
69
|
-
placeholder_chars = placeholder.chars
|
|
70
|
-
chars.insert(cursor_pos, *placeholder_chars)
|
|
71
|
-
lines[line_index] = chars.join
|
|
72
|
-
cursor_pos += placeholder_chars.length
|
|
73
|
-
else
|
|
74
|
-
# Single line rapid input - insert at cursor (use chars for UTF-8)
|
|
75
|
-
chars = (lines[line_index] || "").chars
|
|
76
|
-
pasted_chars = pasted_text.chars
|
|
77
|
-
chars.insert(cursor_pos, *pasted_chars)
|
|
78
|
-
lines[line_index] = chars.join
|
|
79
|
-
cursor_pos += pasted_chars.length
|
|
80
|
-
end
|
|
81
|
-
next
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
case key
|
|
85
|
-
when "\n" # Shift+Enter - newline (Linux/Mac sends \n for Shift+Enter in some terminals)
|
|
86
|
-
# Add new line
|
|
87
|
-
if lines[line_index]
|
|
88
|
-
# Split current line at cursor (use chars for UTF-8)
|
|
89
|
-
chars = lines[line_index].chars
|
|
90
|
-
lines[line_index] = chars[0...cursor_pos].join
|
|
91
|
-
lines.insert(line_index + 1, chars[cursor_pos..-1].join || "")
|
|
92
|
-
else
|
|
93
|
-
lines.insert(line_index + 1, "")
|
|
94
|
-
end
|
|
95
|
-
line_index += 1
|
|
96
|
-
cursor_pos = 0
|
|
97
|
-
|
|
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
|
-
|
|
124
|
-
# Submit if not empty
|
|
125
|
-
unless input_text.empty? && @images.empty?
|
|
126
|
-
clear_simple_prompt(lines.size)
|
|
127
|
-
# Replace placeholders with actual pasted content
|
|
128
|
-
final_text = expand_placeholders(lines.join("\n"))
|
|
129
|
-
return { text: final_text, images: @images.dup }
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
when "\u0003" # Ctrl+C
|
|
133
|
-
# Check if input is empty
|
|
134
|
-
has_content = lines.any? { |line| !line.strip.empty? } || @images.any?
|
|
135
|
-
|
|
136
|
-
if has_content
|
|
137
|
-
# Input has content - clear it on first Ctrl+C
|
|
138
|
-
current_time = Time.now.to_f
|
|
139
|
-
time_since_last = @last_ctrl_c_time ? (current_time - @last_ctrl_c_time) : Float::INFINITY
|
|
140
|
-
|
|
141
|
-
if time_since_last < 2.0 # Within 2 seconds of last Ctrl+C
|
|
142
|
-
# Second Ctrl+C within 2 seconds - exit
|
|
143
|
-
clear_simple_prompt(lines.size)
|
|
144
|
-
return nil
|
|
145
|
-
else
|
|
146
|
-
# First Ctrl+C - clear content
|
|
147
|
-
@last_ctrl_c_time = current_time
|
|
148
|
-
lines = []
|
|
149
|
-
@images = []
|
|
150
|
-
cursor_pos = 0
|
|
151
|
-
line_index = 0
|
|
152
|
-
@paste_counter = 0
|
|
153
|
-
@paste_placeholders = {}
|
|
154
|
-
end
|
|
155
|
-
else
|
|
156
|
-
# Input is empty - exit immediately
|
|
157
|
-
clear_simple_prompt(lines.size)
|
|
158
|
-
return nil
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
when "\u0016" # Ctrl+V - Paste
|
|
162
|
-
pasted = paste_from_clipboard
|
|
163
|
-
if pasted[:type] == :image
|
|
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
|
|
198
|
-
else
|
|
199
|
-
# Handle pasted text
|
|
200
|
-
pasted_text = pasted[:text]
|
|
201
|
-
pasted_lines = pasted_text.split(/\r\n|\r|\n/)
|
|
202
|
-
|
|
203
|
-
if pasted_lines.size > 1
|
|
204
|
-
# Multi-line paste - use placeholder for display
|
|
205
|
-
@paste_counter += 1
|
|
206
|
-
placeholder = "[##{@paste_counter} Paste Text]"
|
|
207
|
-
@paste_placeholders[placeholder] = pasted_text
|
|
208
|
-
|
|
209
|
-
# Insert placeholder at cursor position
|
|
210
|
-
chars = (lines[line_index] || "").chars
|
|
211
|
-
placeholder_chars = placeholder.chars
|
|
212
|
-
chars.insert(cursor_pos, *placeholder_chars)
|
|
213
|
-
lines[line_index] = chars.join
|
|
214
|
-
cursor_pos += placeholder_chars.length
|
|
215
|
-
else
|
|
216
|
-
# Single line paste - insert at cursor (use chars for UTF-8)
|
|
217
|
-
chars = (lines[line_index] || "").chars
|
|
218
|
-
pasted_chars = pasted_text.chars
|
|
219
|
-
chars.insert(cursor_pos, *pasted_chars)
|
|
220
|
-
lines[line_index] = chars.join
|
|
221
|
-
cursor_pos += pasted_chars.length
|
|
222
|
-
end
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
when "\u007F", "\b" # Backspace
|
|
226
|
-
if cursor_pos > 0
|
|
227
|
-
# Delete character before cursor (use chars for UTF-8)
|
|
228
|
-
chars = (lines[line_index] || "").chars
|
|
229
|
-
chars.delete_at(cursor_pos - 1)
|
|
230
|
-
lines[line_index] = chars.join
|
|
231
|
-
cursor_pos -= 1
|
|
232
|
-
elsif line_index > 0
|
|
233
|
-
# Join with previous line
|
|
234
|
-
prev_line = lines[line_index - 1]
|
|
235
|
-
current_line = lines[line_index]
|
|
236
|
-
lines.delete_at(line_index)
|
|
237
|
-
line_index -= 1
|
|
238
|
-
cursor_pos = prev_line.chars.length
|
|
239
|
-
lines[line_index] = prev_line + current_line
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
when "\e[A" # Up arrow
|
|
243
|
-
if line_index > 0
|
|
244
|
-
line_index -= 1
|
|
245
|
-
cursor_pos = [cursor_pos, (lines[line_index] || "").chars.length].min
|
|
246
|
-
end
|
|
247
|
-
|
|
248
|
-
when "\e[B" # Down arrow
|
|
249
|
-
if line_index < lines.size - 1
|
|
250
|
-
line_index += 1
|
|
251
|
-
cursor_pos = [cursor_pos, (lines[line_index] || "").chars.length].min
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
when "\e[C" # Right arrow
|
|
255
|
-
current_line = lines[line_index] || ""
|
|
256
|
-
cursor_pos = [cursor_pos + 1, current_line.chars.length].min
|
|
257
|
-
|
|
258
|
-
when "\e[D" # Left arrow
|
|
259
|
-
cursor_pos = [cursor_pos - 1, 0].max
|
|
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
|
-
|
|
320
|
-
when "\u0004" # Ctrl+D - Delete image by number
|
|
321
|
-
if @images.any?
|
|
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
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
|
|
398
|
-
else
|
|
399
|
-
# Regular character input - support UTF-8
|
|
400
|
-
if key.length >= 1 && key != "\e" && !key.start_with?("\e") && key.ord >= 32
|
|
401
|
-
lines[line_index] ||= ""
|
|
402
|
-
current_line = lines[line_index]
|
|
403
|
-
|
|
404
|
-
# Insert character at cursor position (using character index, not byte index)
|
|
405
|
-
chars = current_line.chars
|
|
406
|
-
chars.insert(cursor_pos, key)
|
|
407
|
-
lines[line_index] = chars.join
|
|
408
|
-
cursor_pos += 1
|
|
409
|
-
end
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
# Ensure we have at least one line
|
|
413
|
-
lines << "" if lines.empty?
|
|
414
|
-
end
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
private
|
|
418
|
-
|
|
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"
|
|
423
|
-
|
|
424
|
-
lines_to_display = []
|
|
425
|
-
|
|
426
|
-
# Get terminal width for full-width separator
|
|
427
|
-
term_width = TTY::Screen.width
|
|
428
|
-
|
|
429
|
-
# Top separator line (full width)
|
|
430
|
-
lines_to_display << @pastel.dim("─" * term_width)
|
|
431
|
-
|
|
432
|
-
# Show images if any
|
|
433
|
-
if @images.any?
|
|
434
|
-
@images.each_with_index do |img_path, idx|
|
|
435
|
-
filename = File.basename(img_path)
|
|
436
|
-
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
437
|
-
line = @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize}) (Ctrl+D to delete)")
|
|
438
|
-
lines_to_display << line
|
|
439
|
-
end
|
|
440
|
-
end
|
|
441
|
-
|
|
442
|
-
# Display input lines
|
|
443
|
-
display_lines = lines.empty? ? [""] : lines
|
|
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
|
|
460
|
-
else
|
|
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}"
|
|
474
|
-
end
|
|
475
|
-
end
|
|
476
|
-
end
|
|
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
|
|
487
|
-
end
|
|
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
|
|
495
|
-
end
|
|
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
|
|
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"
|
|
514
|
-
end
|
|
515
|
-
|
|
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
|
|
521
|
-
end
|
|
522
|
-
|
|
523
|
-
# Clear simple prompt display
|
|
524
|
-
def clear_simple_prompt(num_lines)
|
|
525
|
-
if @last_display_lines && @last_display_lines > 0
|
|
526
|
-
# Move up to the first line (N-1 times since we're on line N)
|
|
527
|
-
(@last_display_lines - 1).times do
|
|
528
|
-
print "\e[1A" # Move up one line
|
|
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"
|
|
538
|
-
end
|
|
539
|
-
print "\r" # Move to beginning of line
|
|
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
|
|
552
|
-
end
|
|
553
|
-
|
|
554
|
-
# Read a single key press with escape sequence handling
|
|
555
|
-
# Handles UTF-8 multi-byte characters correctly
|
|
556
|
-
# Also detects rapid input (paste-like behavior)
|
|
557
|
-
def read_key_with_rapid_detection
|
|
558
|
-
$stdin.set_encoding('UTF-8')
|
|
559
|
-
|
|
560
|
-
current_time = Time.now.to_f
|
|
561
|
-
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
562
|
-
@last_input_time = current_time
|
|
563
|
-
|
|
564
|
-
$stdin.raw do |io|
|
|
565
|
-
io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
|
|
566
|
-
c = io.getc
|
|
567
|
-
|
|
568
|
-
# Ensure character is UTF-8 encoded
|
|
569
|
-
c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
|
|
570
|
-
|
|
571
|
-
# Handle escape sequences (arrow keys, special keys)
|
|
572
|
-
if c == "\e"
|
|
573
|
-
# Read the next character to determine sequence type
|
|
574
|
-
begin
|
|
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
|
|
595
|
-
rescue IO::WaitReadable, Errno::EAGAIN
|
|
596
|
-
# No more characters available
|
|
597
|
-
end
|
|
598
|
-
return c
|
|
599
|
-
end
|
|
600
|
-
|
|
601
|
-
# Check if there are more characters available using IO.select with timeout 0
|
|
602
|
-
has_more_input = IO.select([io], nil, nil, 0)
|
|
603
|
-
|
|
604
|
-
# If this is rapid input or there are more characters available
|
|
605
|
-
if is_rapid_input || has_more_input
|
|
606
|
-
# Buffer rapid input
|
|
607
|
-
buffer = c.to_s.dup
|
|
608
|
-
buffer.force_encoding('UTF-8')
|
|
609
|
-
|
|
610
|
-
# Keep reading available characters
|
|
611
|
-
loop do
|
|
612
|
-
begin
|
|
613
|
-
next_char = io.read_nonblock(1)
|
|
614
|
-
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
615
|
-
buffer << next_char
|
|
616
|
-
|
|
617
|
-
# Continue only if more characters are immediately available
|
|
618
|
-
break unless IO.select([io], nil, nil, 0)
|
|
619
|
-
rescue IO::WaitReadable, Errno::EAGAIN
|
|
620
|
-
break
|
|
621
|
-
end
|
|
622
|
-
end
|
|
623
|
-
|
|
624
|
-
# Ensure buffer is UTF-8
|
|
625
|
-
buffer.force_encoding('UTF-8')
|
|
626
|
-
|
|
627
|
-
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
628
|
-
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
629
|
-
# Remove any trailing \r or \n from rapid input buffer
|
|
630
|
-
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
631
|
-
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
632
|
-
end
|
|
633
|
-
|
|
634
|
-
# Single character rapid input, return as-is
|
|
635
|
-
return buffer[0] if buffer.length == 1
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
c
|
|
639
|
-
end
|
|
640
|
-
rescue Errno::EINTR
|
|
641
|
-
"\u0003" # Treat interrupt as Ctrl+C
|
|
642
|
-
end
|
|
643
|
-
|
|
644
|
-
# Legacy method for compatibility
|
|
645
|
-
def read_key
|
|
646
|
-
read_key_with_rapid_detection
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
# Paste from clipboard (cross-platform)
|
|
650
|
-
# @return [Hash] { type: :text/:image, text: String, path: String }
|
|
651
|
-
def paste_from_clipboard
|
|
652
|
-
case RbConfig::CONFIG["host_os"]
|
|
653
|
-
when /darwin/i
|
|
654
|
-
paste_from_clipboard_macos
|
|
655
|
-
when /linux/i
|
|
656
|
-
paste_from_clipboard_linux
|
|
657
|
-
when /mswin|mingw|cygwin/i
|
|
658
|
-
paste_from_clipboard_windows
|
|
659
|
-
else
|
|
660
|
-
{ type: :text, text: "" }
|
|
661
|
-
end
|
|
662
|
-
end
|
|
663
|
-
|
|
664
|
-
# Paste from macOS clipboard
|
|
665
|
-
def paste_from_clipboard_macos
|
|
666
|
-
require 'shellwords'
|
|
667
|
-
require 'fileutils'
|
|
668
|
-
|
|
669
|
-
# First check if there's an image in clipboard
|
|
670
|
-
# Use osascript to check clipboard content type
|
|
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")
|
|
672
|
-
|
|
673
|
-
if has_image
|
|
674
|
-
# Create a persistent temporary file (won't be auto-deleted)
|
|
675
|
-
temp_dir = Dir.tmpdir
|
|
676
|
-
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
677
|
-
temp_path = File.join(temp_dir, temp_filename)
|
|
678
|
-
|
|
679
|
-
# Extract image using osascript
|
|
680
|
-
script = <<~APPLESCRIPT
|
|
681
|
-
set png_data to the clipboard as «class PNGf»
|
|
682
|
-
set the_file to open for access POSIX file "#{temp_path}" with write permission
|
|
683
|
-
write png_data to the_file
|
|
684
|
-
close access the_file
|
|
685
|
-
APPLESCRIPT
|
|
686
|
-
|
|
687
|
-
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
688
|
-
|
|
689
|
-
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
690
|
-
return { type: :image, path: temp_path }
|
|
691
|
-
end
|
|
692
|
-
end
|
|
693
|
-
|
|
694
|
-
# No image, try text - ensure UTF-8 encoding
|
|
695
|
-
text = `pbpaste 2>/dev/null`.to_s
|
|
696
|
-
text.force_encoding('UTF-8')
|
|
697
|
-
# Replace invalid UTF-8 sequences with replacement character
|
|
698
|
-
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
699
|
-
{ type: :text, text: text }
|
|
700
|
-
rescue => e
|
|
701
|
-
# Fallback to empty text on error
|
|
702
|
-
{ type: :text, text: "" }
|
|
703
|
-
end
|
|
704
|
-
|
|
705
|
-
# Paste from Linux clipboard
|
|
706
|
-
def paste_from_clipboard_linux
|
|
707
|
-
require 'shellwords'
|
|
708
|
-
|
|
709
|
-
# Check if xclip is available
|
|
710
|
-
if system("which xclip >/dev/null 2>&1")
|
|
711
|
-
# Try to get image first
|
|
712
|
-
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
713
|
-
temp_file.close
|
|
714
|
-
|
|
715
|
-
# Try different image MIME types
|
|
716
|
-
["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
|
|
717
|
-
if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
|
|
718
|
-
if File.size(temp_file.path) > 0
|
|
719
|
-
return { type: :image, path: temp_file.path }
|
|
720
|
-
end
|
|
721
|
-
end
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
# No image, get text - ensure UTF-8 encoding
|
|
725
|
-
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
726
|
-
text.force_encoding('UTF-8')
|
|
727
|
-
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
728
|
-
{ type: :text, text: text }
|
|
729
|
-
elsif system("which xsel >/dev/null 2>&1")
|
|
730
|
-
# Fallback to xsel for text only
|
|
731
|
-
text = `xsel --clipboard --output 2>/dev/null`.to_s
|
|
732
|
-
text.force_encoding('UTF-8')
|
|
733
|
-
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
734
|
-
{ type: :text, text: text }
|
|
735
|
-
else
|
|
736
|
-
{ type: :text, text: "" }
|
|
737
|
-
end
|
|
738
|
-
rescue => e
|
|
739
|
-
{ type: :text, text: "" }
|
|
740
|
-
end
|
|
741
|
-
|
|
742
|
-
# Paste from Windows clipboard
|
|
743
|
-
def paste_from_clipboard_windows
|
|
744
|
-
# Try to get image using PowerShell
|
|
745
|
-
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
746
|
-
temp_file.close
|
|
747
|
-
|
|
748
|
-
ps_script = <<~POWERSHELL
|
|
749
|
-
Add-Type -AssemblyName System.Windows.Forms
|
|
750
|
-
$img = [Windows.Forms.Clipboard]::GetImage()
|
|
751
|
-
if ($img) {
|
|
752
|
-
$img.Save('#{temp_file.path.gsub("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
753
|
-
exit 0
|
|
754
|
-
} else {
|
|
755
|
-
exit 1
|
|
756
|
-
}
|
|
757
|
-
POWERSHELL
|
|
758
|
-
|
|
759
|
-
success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
|
|
760
|
-
|
|
761
|
-
if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
|
|
762
|
-
return { type: :image, path: temp_file.path }
|
|
763
|
-
end
|
|
764
|
-
|
|
765
|
-
# No image, get text - ensure UTF-8 encoding
|
|
766
|
-
text = `powershell -NoProfile -Command "Get-Clipboard" 2>nul`.to_s
|
|
767
|
-
text.force_encoding('UTF-8')
|
|
768
|
-
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
769
|
-
{ type: :text, text: text }
|
|
770
|
-
rescue => e
|
|
771
|
-
{ type: :text, text: "" }
|
|
772
|
-
end
|
|
773
|
-
|
|
774
|
-
# Format file size for display
|
|
775
|
-
def format_filesize(size)
|
|
776
|
-
if size < 1024
|
|
777
|
-
"#{size}B"
|
|
778
|
-
elsif size < 1024 * 1024
|
|
779
|
-
"#{(size / 1024.0).round(1)}KB"
|
|
780
|
-
else
|
|
781
|
-
"#{(size / 1024.0 / 1024.0).round(1)}MB"
|
|
782
|
-
end
|
|
783
|
-
end
|
|
784
|
-
end
|
|
785
|
-
end
|
|
786
|
-
end
|