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
@@ -0,0 +1,1029 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tempfile"
5
+ require_relative "../theme_manager"
6
+ require_relative "../line_editor"
7
+
8
+ module Clacky
9
+ module UI2
10
+ module Components
11
+ # InputArea manages the fixed input area at the bottom of the screen
12
+ # Enhanced with multi-line support, image paste, and more
13
+ class InputArea
14
+ include LineEditor
15
+
16
+ attr_accessor :row
17
+ attr_reader :cursor_position, :line_index, :images, :tips_message, :tips_type
18
+
19
+ def initialize(row: 0)
20
+ @row = row
21
+ @lines = [""]
22
+ @line_index = 0
23
+ @cursor_position = 0
24
+ @history = []
25
+ @history_index = -1
26
+ @pastel = Pastel.new
27
+ @width = TTY::Screen.width
28
+
29
+ @images = []
30
+ @max_images = 3
31
+ @paste_counter = 0
32
+ @paste_placeholders = {}
33
+ @last_ctrl_c_time = nil
34
+ @tips_message = nil
35
+ @tips_type = :info
36
+ @tips_timer = nil
37
+ @last_render_row = nil
38
+
39
+ # Paused state - when InlineInput is active
40
+ @paused = false
41
+
42
+ # Session bar info
43
+ @sessionbar_info = {
44
+ working_dir: nil,
45
+ mode: nil,
46
+ model: nil,
47
+ tasks: 0,
48
+ cost: 0.0,
49
+ status: 'idle' # Workspace status: 'idle' or 'working'
50
+ }
51
+
52
+ # Animation state for working status
53
+ @animation_frame = 0
54
+ @last_animation_update = Time.now
55
+ @working_frames = ["❄", "❅", "❆"]
56
+ end
57
+
58
+ # Get current theme from ThemeManager
59
+ def theme
60
+ UI2::ThemeManager.current_theme
61
+ end
62
+
63
+ # Get prompt symbol from theme
64
+ def prompt
65
+ "#{theme.symbol(:user)} "
66
+ end
67
+
68
+ def required_height
69
+ # When paused (InlineInput active), don't take up any space
70
+ return 0 if @paused
71
+
72
+ height = 1 # Session bar (top)
73
+ height += 1 # Separator after session bar
74
+ height += @images.size
75
+
76
+ # Calculate height considering wrapped lines
77
+ @lines.each_with_index do |line, idx|
78
+ prefix = if idx == 0
79
+ prompt
80
+ else
81
+ " " * prompt.length
82
+ end
83
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
84
+ available_width = [@width - prefix_width, 20].max # At least 20 chars
85
+ wrapped_segments = wrap_line(line, available_width)
86
+ height += wrapped_segments.size
87
+ end
88
+
89
+ height += 1 # Bottom separator
90
+ height += 1 if @tips_message
91
+ height
92
+ end
93
+
94
+ # Update session bar info
95
+ # @param working_dir [String] Working directory
96
+ # @param mode [String] Permission mode
97
+ # @param model [String] AI model name
98
+ # @param tasks [Integer] Number of completed tasks
99
+ # @param cost [Float] Total cost
100
+ # @param status [String] Workspace status ('idle' or 'working')
101
+ def update_sessionbar(working_dir: nil, mode: nil, model: nil, tasks: nil, cost: nil, status: nil)
102
+ @sessionbar_info[:working_dir] = working_dir if working_dir
103
+ @sessionbar_info[:mode] = mode if mode
104
+ @sessionbar_info[:model] = model if model
105
+ @sessionbar_info[:tasks] = tasks if tasks
106
+ @sessionbar_info[:cost] = cost if cost
107
+ @sessionbar_info[:status] = status if status
108
+ end
109
+
110
+ def input_buffer
111
+ @lines.join("\n")
112
+ end
113
+
114
+ def handle_key(key)
115
+ # Ignore input when paused (InlineInput is active)
116
+ return { action: nil } if @paused
117
+
118
+ old_height = required_height
119
+
120
+ result = case key
121
+ when Hash
122
+ if key[:type] == :rapid_input
123
+ insert_text(key[:text])
124
+ clear_tips
125
+ end
126
+ { action: nil }
127
+ when :enter then handle_enter
128
+ when :newline then newline; { action: nil }
129
+ when :backspace then backspace; { action: nil }
130
+ when :delete then delete_char; { action: nil }
131
+ when :left_arrow, :ctrl_b then cursor_left; { action: nil }
132
+ when :right_arrow, :ctrl_f then cursor_right; { action: nil }
133
+ when :up_arrow then handle_up_arrow
134
+ when :down_arrow then handle_down_arrow
135
+ when :home, :ctrl_a then cursor_home; { action: nil }
136
+ when :end, :ctrl_e then cursor_end; { action: nil }
137
+ when :ctrl_k then kill_to_end; { action: nil }
138
+ when :ctrl_u then kill_to_start; { action: nil }
139
+ when :ctrl_w then kill_word; { action: nil }
140
+ when :ctrl_c then handle_ctrl_c
141
+ when :ctrl_d then handle_ctrl_d
142
+ when :ctrl_v then handle_paste
143
+ when :shift_tab then { action: :toggle_mode }
144
+ when :escape then { action: nil }
145
+ else
146
+ if key.is_a?(String) && key.length >= 1 && key.ord >= 32
147
+ insert_char(key)
148
+ end
149
+ { action: nil }
150
+ end
151
+
152
+ new_height = required_height
153
+ if new_height != old_height
154
+ result[:height_changed] = true
155
+ result[:new_height] = new_height
156
+ end
157
+
158
+ result
159
+ end
160
+
161
+ def render(start_row:, width: nil)
162
+ @width = width || TTY::Screen.width
163
+ @last_render_row = start_row # Save for tips auto-clear
164
+
165
+ # When paused, don't render anything (InlineInput is active)
166
+ return if @paused
167
+
168
+ current_row = start_row
169
+
170
+ # Session bar at top
171
+ render_sessionbar(current_row)
172
+ current_row += 1
173
+
174
+ # Separator after session bar
175
+ render_separator(current_row)
176
+ current_row += 1
177
+
178
+ # Images
179
+ @images.each_with_index do |img_path, idx|
180
+ move_cursor(current_row, 0)
181
+ filename = File.basename(img_path)
182
+ filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
183
+ content = @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize}) (Ctrl+D to delete)")
184
+ print_with_padding(content)
185
+ current_row += 1
186
+ end
187
+
188
+ # Input lines with auto-wrap support
189
+ @lines.each_with_index do |line, idx|
190
+ prefix = if idx == 0
191
+ prompt_text = theme.format_symbol(:user) + " "
192
+ prompt_text
193
+ else
194
+ " " * prompt.length
195
+ end
196
+
197
+ # Calculate available width for text (excluding prefix)
198
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
199
+ available_width = @width - prefix_width
200
+
201
+ # Wrap line if needed
202
+ wrapped_segments = wrap_line(line, available_width)
203
+
204
+ wrapped_segments.each_with_index do |segment_info, wrap_idx|
205
+ move_cursor(current_row, 0)
206
+
207
+ segment_text = segment_info[:text]
208
+ segment_start = segment_info[:start]
209
+ segment_end = segment_info[:end]
210
+
211
+ content = if wrap_idx == 0
212
+ # First wrapped line includes prefix
213
+ if idx == @line_index
214
+ "#{prefix}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
215
+ else
216
+ "#{prefix}#{theme.format_text(segment_text, :user)}"
217
+ end
218
+ else
219
+ # Continuation lines have indent matching prefix width
220
+ continuation_indent = " " * prefix_width
221
+ if idx == @line_index
222
+ "#{continuation_indent}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
223
+ else
224
+ "#{continuation_indent}#{theme.format_text(segment_text, :user)}"
225
+ end
226
+ end
227
+
228
+ print_with_padding(content)
229
+ current_row += 1
230
+ end
231
+ end
232
+
233
+ # Bottom separator
234
+ render_separator(current_row)
235
+ current_row += 1
236
+
237
+ # Tips bar (if any)
238
+ if @tips_message
239
+ move_cursor(current_row, 0)
240
+ content = format_tips(@tips_message, @tips_type)
241
+ print_with_padding(content)
242
+ current_row += 1
243
+ end
244
+
245
+ # Position cursor at current edit position
246
+ position_cursor(start_row)
247
+ flush
248
+ end
249
+
250
+ def position_cursor(start_row)
251
+ # Calculate which wrapped line the cursor is on
252
+ cursor_row = start_row + 2 + @images.size # session_bar + separator + images
253
+
254
+ # Add rows for lines before current line
255
+ @lines[0...@line_index].each_with_index do |line, idx|
256
+ prefix = if idx == 0
257
+ prompt
258
+ else
259
+ " " * prompt.length
260
+ end
261
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
262
+ available_width = [@width - prefix_width, 20].max
263
+ wrapped_segments = wrap_line(line, available_width)
264
+ cursor_row += wrapped_segments.size
265
+ end
266
+
267
+ # Find which wrapped segment of current line contains cursor
268
+ current = current_line
269
+ prefix = if @line_index == 0
270
+ prompt
271
+ else
272
+ " " * prompt.length
273
+ end
274
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
275
+ available_width = [@width - prefix_width, 20].max
276
+ wrapped_segments = wrap_line(current, available_width)
277
+
278
+ # Find cursor segment and position within segment
279
+ cursor_segment_idx = 0
280
+ cursor_pos_in_segment = @cursor_position
281
+
282
+ wrapped_segments.each_with_index do |segment, idx|
283
+ if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
284
+ cursor_segment_idx = idx
285
+ cursor_pos_in_segment = @cursor_position - segment[:start]
286
+ break
287
+ elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
288
+ # Cursor at very end
289
+ cursor_segment_idx = idx
290
+ cursor_pos_in_segment = segment[:end] - segment[:start]
291
+ break
292
+ end
293
+ end
294
+
295
+ cursor_row += cursor_segment_idx
296
+
297
+ # Calculate display width of text before cursor in this segment
298
+ chars = current.chars
299
+ segment_start = wrapped_segments[cursor_segment_idx][:start]
300
+ text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
301
+ display_width = calculate_display_width(text_in_segment_before_cursor)
302
+
303
+ cursor_col = prefix_width + display_width
304
+ move_cursor(cursor_row, cursor_col)
305
+ end
306
+
307
+ def set_tips(message, type: :info)
308
+ # Cancel existing timer if any
309
+ if @tips_timer&.alive?
310
+ @tips_timer.kill
311
+ end
312
+
313
+ @tips_message = message
314
+ @tips_type = type
315
+
316
+ # Auto-clear tips after 2 seconds
317
+ @tips_timer = Thread.new do
318
+ sleep 2
319
+ # Clear tips from state and screen
320
+ @tips_message = nil
321
+ # Tips row: start_row + session_bar(1) + separator(1) + images + lines + separator(1)
322
+ tips_row = @last_render_row + 2 + @images.size + @lines.size + 1
323
+ move_cursor(tips_row, 0)
324
+ clear_line
325
+ flush
326
+ end
327
+ end
328
+
329
+ def clear_tips
330
+ # Cancel timer if any
331
+ if @tips_timer&.alive?
332
+ @tips_timer.kill
333
+ end
334
+ @tips_message = nil
335
+ end
336
+
337
+ # Pause input area (when InlineInput is active)
338
+ def pause
339
+ @paused = true
340
+ end
341
+
342
+ # Resume input area (when InlineInput is done)
343
+ def resume
344
+ @paused = false
345
+ end
346
+
347
+ # Check if paused
348
+ def paused?
349
+ @paused
350
+ end
351
+
352
+ def current_content
353
+ text = expand_placeholders(@lines.join("\n"))
354
+
355
+ # If both text and images are empty, return empty string
356
+ return "" if text.empty? && @images.empty?
357
+
358
+ # Format user input with color and spacing from theme
359
+ symbol = theme.format_symbol(:user)
360
+ content = theme.format_text(text, :user)
361
+
362
+ result = "\n#{symbol} #{content}\n"
363
+
364
+ # Append image information if present
365
+ if @images && @images.any?
366
+ @images.each_with_index do |img_path, idx|
367
+ filename = File.basename(img_path)
368
+ filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
369
+ result += @pastel.dim(" [Image #{idx + 1}] #{filename} (#{filesize})") + "\n"
370
+ end
371
+ end
372
+
373
+ result
374
+ end
375
+
376
+ def current_value
377
+ expand_placeholders(@lines.join("\n"))
378
+ end
379
+
380
+ def empty?
381
+ @lines.all?(&:empty?) && @images.empty?
382
+ end
383
+
384
+ def multiline?
385
+ @lines.size > 1
386
+ end
387
+
388
+ def has_images?
389
+ @images.any?
390
+ end
391
+
392
+ def set_prompt(prompt)
393
+ prompt = prompt
394
+ end
395
+
396
+ # --- Public editing methods ---
397
+
398
+ def insert_char(char)
399
+ chars = current_line.chars
400
+ chars.insert(@cursor_position, char)
401
+ @lines[@line_index] = chars.join
402
+ @cursor_position += 1
403
+ end
404
+
405
+ def backspace
406
+ if @cursor_position > 0
407
+ chars = current_line.chars
408
+ chars.delete_at(@cursor_position - 1)
409
+ @lines[@line_index] = chars.join
410
+ @cursor_position -= 1
411
+ elsif @line_index > 0
412
+ prev_line = @lines[@line_index - 1]
413
+ current = @lines[@line_index]
414
+ @lines.delete_at(@line_index)
415
+ @line_index -= 1
416
+ @cursor_position = prev_line.chars.length
417
+ @lines[@line_index] = prev_line + current
418
+ end
419
+ end
420
+
421
+ def delete_char
422
+ chars = current_line.chars
423
+ return if @cursor_position >= chars.length
424
+ chars.delete_at(@cursor_position)
425
+ @lines[@line_index] = chars.join
426
+ end
427
+
428
+ def cursor_left
429
+ @cursor_position = [@cursor_position - 1, 0].max
430
+ end
431
+
432
+ def cursor_right
433
+ @cursor_position = [@cursor_position + 1, current_line.chars.length].min
434
+ end
435
+
436
+ def cursor_home
437
+ @cursor_position = 0
438
+ end
439
+
440
+ def cursor_end
441
+ @cursor_position = current_line.chars.length
442
+ end
443
+
444
+ def clear
445
+ @lines = [""]
446
+ @line_index = 0
447
+ @cursor_position = 0
448
+ @history_index = -1
449
+ @images = []
450
+ @paste_counter = 0
451
+ @paste_placeholders = {}
452
+ clear_tips
453
+ end
454
+
455
+ def submit
456
+ text = current_value
457
+ imgs = @images.dup
458
+ add_to_history(text) unless text.empty?
459
+ clear
460
+ { text: text, images: imgs }
461
+ end
462
+
463
+ def history_prev
464
+ return if @history.empty?
465
+ if @history_index == -1
466
+ @history_index = @history.size - 1
467
+ else
468
+ @history_index = [@history_index - 1, 0].max
469
+ end
470
+ load_history_entry
471
+ end
472
+
473
+ def history_next
474
+ return if @history_index == -1
475
+ @history_index += 1
476
+ if @history_index >= @history.size
477
+ @history_index = -1
478
+ @lines = [""]
479
+ @line_index = 0
480
+ @cursor_position = 0
481
+ else
482
+ load_history_entry
483
+ end
484
+ end
485
+
486
+ private
487
+
488
+ # Wrap a line into multiple segments based on available width
489
+ # Considers display width of characters (multi-byte characters like Chinese)
490
+ # @param line [String] The line to wrap
491
+ # @param max_width [Integer] Maximum display width per wrapped line
492
+ # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
493
+ def wrap_line(line, max_width)
494
+ return [{ text: "", start: 0, end: 0 }] if line.empty?
495
+ return [{ text: line, start: 0, end: line.length }] if max_width <= 0
496
+
497
+ segments = []
498
+ chars = line.chars
499
+ segment_start = 0
500
+ current_width = 0
501
+ current_end = 0
502
+
503
+ chars.each_with_index do |char, idx|
504
+ char_width = char_display_width(char)
505
+
506
+ # If adding this character exceeds max width, complete current segment
507
+ if current_width + char_width > max_width && current_end > segment_start
508
+ segments << {
509
+ text: chars[segment_start...current_end].join,
510
+ start: segment_start,
511
+ end: current_end
512
+ }
513
+ segment_start = idx
514
+ current_end = idx + 1
515
+ current_width = char_width
516
+ else
517
+ current_end = idx + 1
518
+ current_width += char_width
519
+ end
520
+ end
521
+
522
+ # Add the last segment
523
+ if current_end > segment_start
524
+ segments << {
525
+ text: chars[segment_start...current_end].join,
526
+ start: segment_start,
527
+ end: current_end
528
+ }
529
+ end
530
+
531
+ segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
532
+ end
533
+
534
+ # Calculate display width of a single character
535
+ # @param char [String] Single character
536
+ # @return [Integer] Display width (1 or 2)
537
+ def char_display_width(char)
538
+ code = char.ord
539
+ # East Asian Wide and Fullwidth characters take 2 columns
540
+ if (code >= 0x1100 && code <= 0x115F) ||
541
+ (code >= 0x2329 && code <= 0x232A) ||
542
+ (code >= 0x2E80 && code <= 0x303E) ||
543
+ (code >= 0x3040 && code <= 0xA4CF) ||
544
+ (code >= 0xAC00 && code <= 0xD7A3) ||
545
+ (code >= 0xF900 && code <= 0xFAFF) ||
546
+ (code >= 0xFE10 && code <= 0xFE19) ||
547
+ (code >= 0xFE30 && code <= 0xFE6F) ||
548
+ (code >= 0xFF00 && code <= 0xFF60) ||
549
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
550
+ (code >= 0x1F300 && code <= 0x1F9FF) ||
551
+ (code >= 0x20000 && code <= 0x2FFFD) ||
552
+ (code >= 0x30000 && code <= 0x3FFFD)
553
+ 2
554
+ else
555
+ 1
556
+ end
557
+ end
558
+
559
+ # Strip ANSI escape codes from a string
560
+ # @param text [String] Text with ANSI codes
561
+ # @return [String] Text without ANSI codes
562
+ def strip_ansi_codes(text)
563
+ text.gsub(/\e\[[0-9;]*m/, '')
564
+ end
565
+
566
+ # Print content and pad with spaces to clear any remaining characters from previous render
567
+ # This avoids flickering from clear_line while ensuring old content is erased
568
+ def print_with_padding(content)
569
+ # Calculate visible width (strip ANSI codes for width calculation)
570
+ visible_content = content.gsub(/\e\[[0-9;]*m/, '')
571
+ visible_width = calculate_display_width(visible_content)
572
+
573
+ # Print content
574
+ print content
575
+
576
+ # Pad with spaces if needed to clear old content
577
+ remaining = @width - visible_width
578
+ print " " * remaining if remaining > 0
579
+ end
580
+
581
+ def handle_enter
582
+ text = current_value.strip
583
+
584
+ # Handle commands (with or without slash)
585
+ if text.start_with?('/')
586
+ case text
587
+ when '/clear'
588
+ clear
589
+ return { action: :clear_output }
590
+ when '/help'
591
+ return { action: :help }
592
+ when '/exit', '/quit'
593
+ return { action: :exit }
594
+ else
595
+ set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
596
+ return { action: nil }
597
+ end
598
+ elsif text == '?'
599
+ return { action: :help }
600
+ elsif text == 'exit' || text == 'quit'
601
+ return { action: :exit }
602
+ end
603
+
604
+ if text.empty? && @images.empty?
605
+ return { action: nil }
606
+ end
607
+
608
+ content_to_display = current_content
609
+ result_text = current_value
610
+ result_images = @images.dup
611
+
612
+ add_to_history(result_text) unless result_text.empty?
613
+ clear
614
+
615
+ { action: :submit, data: { text: result_text, images: result_images, display: content_to_display } }
616
+ end
617
+
618
+ def handle_up_arrow
619
+ if multiline?
620
+ unless cursor_up
621
+ history_prev
622
+ end
623
+ else
624
+ # Navigate history when single line (empty or not)
625
+ history_prev
626
+ end
627
+ { action: nil }
628
+ end
629
+
630
+ def handle_down_arrow
631
+ if multiline?
632
+ unless cursor_down
633
+ history_next
634
+ end
635
+ else
636
+ # Navigate history when single line (empty or not)
637
+ history_next
638
+ end
639
+ { action: nil }
640
+ end
641
+
642
+ def handle_ctrl_c
643
+ { action: :interrupt }
644
+ end
645
+
646
+ def handle_ctrl_d
647
+ if has_images?
648
+ if @images.size == 1
649
+ @images.clear
650
+ else
651
+ @images.shift
652
+ end
653
+ clear_tips
654
+ { action: nil }
655
+ elsif empty?
656
+ { action: :exit }
657
+ else
658
+ { action: nil }
659
+ end
660
+ end
661
+
662
+ def handle_paste
663
+ pasted = paste_from_clipboard
664
+ if pasted[:type] == :image
665
+ if @images.size < @max_images
666
+ @images << pasted[:path]
667
+ clear_tips
668
+ else
669
+ set_tips("Maximum #{@max_images} images allowed. Delete an image first (Ctrl+D).", type: :warning)
670
+ end
671
+ else
672
+ insert_text(pasted[:text])
673
+ clear_tips
674
+ end
675
+ { action: nil }
676
+ end
677
+
678
+ def insert_text(text)
679
+ return if text.nil? || text.empty?
680
+
681
+ text_lines = text.split(/\r\n|\r|\n/)
682
+
683
+ if text_lines.size > 1
684
+ @paste_counter += 1
685
+ placeholder = "[##{@paste_counter} Paste Text]"
686
+ @paste_placeholders[placeholder] = text
687
+
688
+ chars = current_line.chars
689
+ chars.insert(@cursor_position, *placeholder.chars)
690
+ @lines[@line_index] = chars.join
691
+ @cursor_position += placeholder.length
692
+ else
693
+ chars = current_line.chars
694
+ text.chars.each_with_index do |c, i|
695
+ chars.insert(@cursor_position + i, c)
696
+ end
697
+ @lines[@line_index] = chars.join
698
+ @cursor_position += text.length
699
+ end
700
+ end
701
+
702
+ def newline
703
+ chars = current_line.chars
704
+ @lines[@line_index] = chars[0...@cursor_position].join
705
+ @lines.insert(@line_index + 1, chars[@cursor_position..-1]&.join || "")
706
+ @line_index += 1
707
+ @cursor_position = 0
708
+ end
709
+
710
+ def cursor_up
711
+ return false if @line_index == 0
712
+ @line_index -= 1
713
+ @cursor_position = [@cursor_position, current_line.chars.length].min
714
+ true
715
+ end
716
+
717
+ def cursor_down
718
+ return false if @line_index >= @lines.size - 1
719
+ @line_index += 1
720
+ @cursor_position = [@cursor_position, current_line.chars.length].min
721
+ true
722
+ end
723
+
724
+ def kill_to_end
725
+ chars = current_line.chars
726
+ @lines[@line_index] = chars[0...@cursor_position].join
727
+ end
728
+
729
+ def kill_to_start
730
+ chars = current_line.chars
731
+ @lines[@line_index] = chars[@cursor_position..-1]&.join || ""
732
+ @cursor_position = 0
733
+ end
734
+
735
+ def kill_word
736
+ chars = current_line.chars
737
+ pos = @cursor_position - 1
738
+
739
+ while pos >= 0 && chars[pos] =~ /\s/
740
+ pos -= 1
741
+ end
742
+ while pos >= 0 && chars[pos] =~ /\S/
743
+ pos -= 1
744
+ end
745
+
746
+ delete_start = pos + 1
747
+ chars.slice!(delete_start...@cursor_position)
748
+ @lines[@line_index] = chars.join
749
+ @cursor_position = delete_start
750
+ end
751
+
752
+ def load_history_entry
753
+ return unless @history_index >= 0 && @history_index < @history.size
754
+ entry = @history[@history_index]
755
+ @lines = entry.split("\n")
756
+ @lines = [""] if @lines.empty?
757
+ @line_index = @lines.size - 1
758
+ @cursor_position = current_line.chars.length
759
+ end
760
+
761
+ def add_to_history(entry)
762
+ @history << entry
763
+ @history = @history.last(100) if @history.size > 100
764
+ end
765
+
766
+ def paste_from_clipboard
767
+ case RbConfig::CONFIG["host_os"]
768
+ when /darwin/i
769
+ paste_from_clipboard_macos
770
+ when /linux/i
771
+ paste_from_clipboard_linux
772
+ else
773
+ { type: :text, text: "" }
774
+ end
775
+ end
776
+
777
+ def paste_from_clipboard_macos
778
+ 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")
779
+
780
+ if has_image
781
+ temp_dir = Dir.tmpdir
782
+ temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
783
+ temp_path = File.join(temp_dir, temp_filename)
784
+
785
+ script = <<~APPLESCRIPT
786
+ set png_data to the clipboard as «class PNGf»
787
+ set the_file to open for access POSIX file "#{temp_path}" with write permission
788
+ write png_data to the_file
789
+ close access the_file
790
+ APPLESCRIPT
791
+
792
+ success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
793
+
794
+ if success && File.exist?(temp_path) && File.size(temp_path) > 0
795
+ return { type: :image, path: temp_path }
796
+ end
797
+ end
798
+
799
+ text = `pbpaste 2>/dev/null`.to_s
800
+ text.force_encoding('UTF-8')
801
+ text = text.encode('UTF-8', invalid: :replace, undef: :replace)
802
+ { type: :text, text: text }
803
+ rescue => e
804
+ { type: :text, text: "" }
805
+ end
806
+
807
+ def paste_from_clipboard_linux
808
+ if system("which xclip >/dev/null 2>&1")
809
+ text = `xclip -selection clipboard -o 2>/dev/null`.to_s
810
+ text.force_encoding('UTF-8')
811
+ text = text.encode('UTF-8', invalid: :replace, undef: :replace)
812
+ { type: :text, text: text }
813
+ elsif system("which xsel >/dev/null 2>&1")
814
+ text = `xsel --clipboard --output 2>/dev/null`.to_s
815
+ text.force_encoding('UTF-8')
816
+ text = text.encode('UTF-8', invalid: :replace, undef: :replace)
817
+ { type: :text, text: text }
818
+ else
819
+ { type: :text, text: "" }
820
+ end
821
+ rescue => e
822
+ { type: :text, text: "" }
823
+ end
824
+
825
+ def current_line
826
+ @lines[@line_index] || ""
827
+ end
828
+
829
+ def expand_placeholders(text)
830
+ super(text, @paste_placeholders)
831
+ end
832
+
833
+ def render_line_with_cursor(line)
834
+ chars = line.chars
835
+ before_cursor = chars[0...@cursor_position].join
836
+ cursor_char = chars[@cursor_position] || " "
837
+ after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
838
+
839
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
840
+ end
841
+
842
+ # Render a segment of a line with cursor if cursor is in this segment
843
+ # @param line [String] Full line text
844
+ # @param segment_start [Integer] Start position of segment in line (char index)
845
+ # @param segment_end [Integer] End position of segment in line (char index)
846
+ # @return [String] Rendered segment with cursor if applicable
847
+ def render_line_segment_with_cursor(line, segment_start, segment_end)
848
+ chars = line.chars
849
+ segment_chars = chars[segment_start...segment_end]
850
+
851
+ # Check if cursor is in this segment
852
+ if @cursor_position >= segment_start && @cursor_position < segment_end
853
+ # Cursor is in this segment
854
+ cursor_pos_in_segment = @cursor_position - segment_start
855
+ before_cursor = segment_chars[0...cursor_pos_in_segment].join
856
+ cursor_char = segment_chars[cursor_pos_in_segment] || " "
857
+ after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
858
+
859
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
860
+ elsif @cursor_position == segment_end && segment_end == line.length
861
+ # Cursor is at the very end of the line, show it in last segment
862
+ segment_text = segment_chars.join
863
+ "#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
864
+ else
865
+ # Cursor is not in this segment, just format normally
866
+ theme.format_text(segment_chars.join, :user)
867
+ end
868
+ end
869
+
870
+ def render_separator(row)
871
+ move_cursor(row, 0)
872
+ content = @pastel.dim("─" * @width)
873
+ print_with_padding(content)
874
+ end
875
+
876
+ def render_sessionbar(row)
877
+ move_cursor(row, 0)
878
+
879
+ # If no sessionbar info, just render a separator
880
+ unless @sessionbar_info[:working_dir]
881
+ content = @pastel.dim("─" * @width)
882
+ print_with_padding(content)
883
+ return
884
+ end
885
+
886
+ parts = []
887
+ separator = @pastel.dim(" │ ")
888
+
889
+ # Workspace status with animation
890
+ if @sessionbar_info[:status]
891
+ status_indicator = get_status_indicator(@sessionbar_info[:status])
892
+ status_theme_key = status_theme_key_for(@sessionbar_info[:status])
893
+ parts << "#{status_indicator} #{theme.format_text(@sessionbar_info[:status], status_theme_key)}"
894
+ end
895
+
896
+ # Working directory (shortened if too long)
897
+ if @sessionbar_info[:working_dir]
898
+ dir_display = shorten_path(@sessionbar_info[:working_dir])
899
+ parts << @pastel.bright_cyan(dir_display)
900
+ end
901
+
902
+ # Permission mode
903
+ if @sessionbar_info[:mode]
904
+ mode_color = mode_color_for(@sessionbar_info[:mode])
905
+ parts << @pastel.public_send(mode_color, @sessionbar_info[:mode])
906
+ end
907
+
908
+ # Model
909
+ if @sessionbar_info[:model]
910
+ parts << @pastel.bright_white(@sessionbar_info[:model])
911
+ end
912
+
913
+ # Tasks count
914
+ parts << @pastel.yellow("#{@sessionbar_info[:tasks]} tasks")
915
+
916
+ # Cost
917
+ cost_display = format("$%.1f", @sessionbar_info[:cost])
918
+ parts << @pastel.yellow(cost_display)
919
+
920
+ session_line = " " + parts.join(separator)
921
+ print_with_padding(session_line)
922
+ end
923
+
924
+ def shorten_path(path)
925
+ return path if path.length <= 40
926
+
927
+ # Replace home directory with ~
928
+ home = ENV["HOME"]
929
+ if home && path.start_with?(home)
930
+ path = path.sub(home, "~")
931
+ end
932
+
933
+ # If still too long, show last parts
934
+ if path.length > 40
935
+ parts = path.split("/")
936
+ if parts.length > 3
937
+ ".../" + parts[-3..-1].join("/")
938
+ else
939
+ path[0..40] + "..."
940
+ end
941
+ else
942
+ path
943
+ end
944
+ end
945
+
946
+ def mode_color_for(mode)
947
+ case mode.to_s
948
+ when /auto_approve/
949
+ :bright_magenta
950
+ when /confirm_safes/
951
+ :bright_yellow
952
+ when /confirm_edits/
953
+ :bright_green
954
+ when /plan_only/
955
+ :bright_blue
956
+ else
957
+ :white
958
+ end
959
+ end
960
+
961
+ def status_theme_key_for(status)
962
+ case status.to_s.downcase
963
+ when 'idle'
964
+ :info # Use info color for idle state
965
+ when 'working'
966
+ :progress # Use progress color for working state
967
+ else
968
+ :info
969
+ end
970
+ end
971
+
972
+ def get_status_indicator(status)
973
+ case status.to_s.downcase
974
+ when 'working'
975
+ # Update animation frame if enough time has passed
976
+ now = Time.now
977
+ if now - @last_animation_update >= 0.3
978
+ @animation_frame = (@animation_frame + 1) % @working_frames.length
979
+ @last_animation_update = now
980
+ end
981
+ @working_frames[@animation_frame]
982
+ else
983
+ "●" # Idle indicator
984
+ end
985
+ end
986
+
987
+ def format_tips(message, type)
988
+ # Limit message length to prevent line wrapping
989
+ # Reserve space for prefix like "[Warn] " (about 8 chars) and some margin
990
+ max_length = @width - 10
991
+ if message.length > max_length
992
+ message = message[0...(max_length - 3)] + "..."
993
+ end
994
+
995
+ case type
996
+ when :warning
997
+ @pastel.dim("[") + @pastel.yellow("Warn") + @pastel.dim("] ") + @pastel.yellow(message)
998
+ when :error
999
+ @pastel.dim("[") + @pastel.red("Error") + @pastel.dim("] ") + @pastel.red(message)
1000
+ else
1001
+ @pastel.dim("[") + @pastel.cyan("Info") + @pastel.dim("] ") + @pastel.white(message)
1002
+ end
1003
+ end
1004
+
1005
+ def format_filesize(size)
1006
+ if size < 1024
1007
+ "#{size}B"
1008
+ elsif size < 1024 * 1024
1009
+ "#{(size / 1024.0).round(1)}KB"
1010
+ else
1011
+ "#{(size / 1024.0 / 1024.0).round(1)}MB"
1012
+ end
1013
+ end
1014
+
1015
+ def move_cursor(row, col)
1016
+ print "\e[#{row + 1};#{col + 1}H"
1017
+ end
1018
+
1019
+ def clear_line
1020
+ print "\e[2K"
1021
+ end
1022
+
1023
+ def flush
1024
+ $stdout.flush
1025
+ end
1026
+ end
1027
+ end
1028
+ end
1029
+ end