openclacky 0.5.2 → 0.5.4

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