openclacky 0.5.6 → 0.6.1

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