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.
@@ -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: "You:")
32
- # @return [Hash, nil] { text: String, images: Array } or nil on EOF
33
- def read_input(prefix: "You:")
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 box
42
- display_prompt_box(lines, prefix, line_index, cursor_pos)
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("\n")
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 lines.join.strip.empty? && @images.empty?
96
- clear_prompt_display(lines.size)
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
- clear_prompt_display(lines.size)
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
- clear_prompt_display(lines.size)
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 << pasted[:path]
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("\n")
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
- 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)
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
- # 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
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
- # 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)
424
+ lines_to_display = []
241
425
 
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
426
+ # Get terminal width for full-width separator
427
+ term_width = TTY::Screen.width
251
428
 
252
- lines_to_display = []
429
+ # Top separator line (full width)
430
+ lines_to_display << @pastel.dim("─" * term_width)
253
431
 
254
- # Display images if any
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
- line_content = " #{idx + 1}. #{filename} (#{filesize})"
262
- display_content = line_content.ljust(width - 2)
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 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
442
+ # Display input lines
278
443
  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 }
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
- # 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
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
- # 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
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
- 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
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
- 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(" │")
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
- # 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
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 after submission
417
- def clear_prompt_display(num_lines)
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
- @last_display_lines.times do
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
- print "\e[2K" # Clear entire 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"
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 2 characters for escape sequences
573
+ # Read the next character to determine sequence type
448
574
  begin
449
- extra = io.read_nonblock(2)
450
- extra = extra.force_encoding('UTF-8') if extra.encoding != Encoding::UTF_8
451
- c = c + extra
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