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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/docs/ui2-architecture.md +124 -0
  4. data/lib/clacky/agent.rb +245 -340
  5. data/lib/clacky/agent_config.rb +1 -7
  6. data/lib/clacky/cli.rb +156 -397
  7. data/lib/clacky/client.rb +68 -36
  8. data/lib/clacky/gitignore_parser.rb +26 -12
  9. data/lib/clacky/model_pricing.rb +6 -2
  10. data/lib/clacky/session_manager.rb +6 -2
  11. data/lib/clacky/tools/glob.rb +65 -9
  12. data/lib/clacky/tools/grep.rb +4 -120
  13. data/lib/clacky/tools/run_project.rb +5 -0
  14. data/lib/clacky/tools/safe_shell.rb +49 -13
  15. data/lib/clacky/tools/shell.rb +1 -49
  16. data/lib/clacky/tools/web_fetch.rb +2 -2
  17. data/lib/clacky/tools/web_search.rb +38 -26
  18. data/lib/clacky/ui2/README.md +214 -0
  19. data/lib/clacky/ui2/components/base_component.rb +163 -0
  20. data/lib/clacky/ui2/components/common_component.rb +89 -0
  21. data/lib/clacky/ui2/components/inline_input.rb +187 -0
  22. data/lib/clacky/ui2/components/input_area.rb +1029 -0
  23. data/lib/clacky/ui2/components/message_component.rb +76 -0
  24. data/lib/clacky/ui2/components/output_area.rb +112 -0
  25. data/lib/clacky/ui2/components/todo_area.rb +137 -0
  26. data/lib/clacky/ui2/components/tool_component.rb +106 -0
  27. data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
  28. data/lib/clacky/ui2/layout_manager.rb +331 -0
  29. data/lib/clacky/ui2/line_editor.rb +201 -0
  30. data/lib/clacky/ui2/screen_buffer.rb +238 -0
  31. data/lib/clacky/ui2/theme_manager.rb +68 -0
  32. data/lib/clacky/ui2/themes/base_theme.rb +99 -0
  33. data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
  34. data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
  35. data/lib/clacky/ui2/ui_controller.rb +720 -0
  36. data/lib/clacky/ui2/view_renderer.rb +160 -0
  37. data/lib/clacky/ui2.rb +37 -0
  38. data/lib/clacky/utils/file_ignore_helper.rb +126 -0
  39. data/lib/clacky/version.rb +1 -1
  40. data/lib/clacky.rb +1 -6
  41. metadata +38 -6
  42. data/lib/clacky/ui/banner.rb +0 -155
  43. data/lib/clacky/ui/enhanced_prompt.rb +0 -786
  44. data/lib/clacky/ui/formatter.rb +0 -209
  45. 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