openclacky 0.6.2 → 0.6.3

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.
@@ -109,24 +109,31 @@ module Clacky
109
109
  # Render inline input with prompt and cursor
110
110
  # @return [String] Rendered line (may wrap to multiple lines)
111
111
  def render
112
- line = render_line_with_cursor
113
- full_text = "#{@prompt}#{line}"
114
-
115
- # Calculate terminal width and check if wrapping is needed
116
112
  width = TTY::Screen.width
117
- visible_text = strip_ansi_codes(full_text)
118
- display_width = calculate_display_width(visible_text)
119
-
120
- # If no wrapping needed, return as is
121
- return full_text if display_width <= width
122
-
123
- # Otherwise, wrap the input (prompt on first line, continuation indented)
124
113
  prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
125
114
  available_width = width - prompt_width
126
-
127
- # For simplicity, just return full text and let terminal handle wrapping
128
- # InlineInput is typically short, so natural wrapping should be fine
129
- full_text
115
+
116
+ # Get wrapped segments
117
+ wrapped_segments = wrap_line(@line, available_width)
118
+
119
+ # Build rendered output with cursor
120
+ output = ""
121
+
122
+ wrapped_segments.each_with_index do |segment, idx|
123
+ prefix = if idx == 0
124
+ @prompt
125
+ else
126
+ "> " # Continuation prompt indicator
127
+ end
128
+
129
+ # Render segment with cursor if needed
130
+ segment_text = render_line_segment_with_cursor(@line, segment[:start], segment[:end])
131
+
132
+ output += "#{prefix}#{segment_text}"
133
+ output += "\n" unless idx == wrapped_segments.size - 1
134
+ end
135
+
136
+ output
130
137
  end
131
138
 
132
139
  # Get cursor column position
@@ -135,6 +142,18 @@ module Clacky
135
142
  cursor_column(@prompt)
136
143
  end
137
144
 
145
+ # Get the number of lines this input will occupy when rendered
146
+ # @param width [Integer] Terminal width
147
+ # @return [Integer] Number of lines
148
+ def line_count(width = TTY::Screen.width)
149
+ prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
150
+ available_width = width - prompt_width
151
+ return 1 if available_width <= 0
152
+
153
+ segments = wrap_line(@line, available_width)
154
+ segments.size
155
+ end
156
+
138
157
  # Deactivate inline input
139
158
  def deactivate
140
159
  @active = false
@@ -4,6 +4,7 @@ require "pastel"
4
4
  require "tempfile"
5
5
  require_relative "../theme_manager"
6
6
  require_relative "../line_editor"
7
+ require_relative "command_suggestions"
7
8
 
8
9
  module Clacky
9
10
  module UI2
@@ -68,6 +69,10 @@ module Clacky
68
69
  @animation_frame = 0
69
70
  @last_animation_update = Time.now
70
71
  @working_frames = ["❄", "❅", "❆"]
72
+
73
+ # Command suggestions dropdown
74
+ @command_suggestions = CommandSuggestions.new
75
+ @skill_loader = nil # Will be set via set_skill_loader method
71
76
  end
72
77
 
73
78
  # Get current theme from ThemeManager
@@ -111,6 +116,9 @@ module Clacky
111
116
  # Bottom separator
112
117
  height += 1
113
118
 
119
+ # Command suggestions (rendered above input)
120
+ height += @command_suggestions.required_height if @command_suggestions
121
+
114
122
  # Tips and user tips
115
123
  height += 1 if @tips_message
116
124
  height += 1 if @user_tip
@@ -118,6 +126,13 @@ module Clacky
118
126
  height
119
127
  end
120
128
 
129
+ # Set skill loader for command suggestions
130
+ # @param skill_loader [Clacky::SkillLoader] The skill loader instance
131
+ def set_skill_loader(skill_loader)
132
+ @skill_loader = skill_loader
133
+ @command_suggestions.load_skill_commands(skill_loader) if skill_loader
134
+ end
135
+
121
136
  # Update session bar info
122
137
  # @param working_dir [String] Working directory
123
138
  # @param mode [String] Permission mode
@@ -144,17 +159,65 @@ module Clacky
144
159
 
145
160
  old_height = required_height
146
161
 
162
+ # Handle command suggestions navigation first if visible
163
+ if @command_suggestions.visible
164
+ case key
165
+ when :up_arrow
166
+ @command_suggestions.select_previous
167
+ return { action: nil }
168
+ when :down_arrow
169
+ @command_suggestions.select_next
170
+ return { action: nil }
171
+ when :enter
172
+ # Accept selected command
173
+ if @command_suggestions.has_suggestions?
174
+ selected = @command_suggestions.selected_command_text
175
+ if selected
176
+ # Replace current input with selected command
177
+ @lines = [selected]
178
+ @line_index = 0
179
+ @cursor_position = selected.length
180
+ @command_suggestions.hide
181
+ return { action: nil }
182
+ end
183
+ end
184
+ # Fall through to normal enter handling if no suggestion
185
+ when :escape
186
+ @command_suggestions.hide
187
+ return { action: nil }
188
+ when :tab
189
+ # Tab also accepts the suggestion
190
+ if @command_suggestions.has_suggestions?
191
+ selected = @command_suggestions.selected_command_text
192
+ if selected
193
+ @lines = [selected]
194
+ @line_index = 0
195
+ @cursor_position = selected.length
196
+ @command_suggestions.hide
197
+ return { action: nil }
198
+ end
199
+ end
200
+ end
201
+ end
202
+
147
203
  result = case key
148
204
  when Hash
149
205
  if key[:type] == :rapid_input
150
206
  insert_text(key[:text])
151
207
  clear_tips
208
+ update_command_suggestions
152
209
  end
153
210
  { action: nil }
154
211
  when :enter then handle_enter
155
212
  when :newline then newline; { action: nil }
156
- when :backspace then backspace; { action: nil }
157
- when :delete then delete_char; { action: nil }
213
+ when :backspace
214
+ backspace
215
+ update_command_suggestions
216
+ { action: nil }
217
+ when :delete
218
+ delete_char
219
+ update_command_suggestions
220
+ { action: nil }
158
221
  when :left_arrow, :ctrl_b then cursor_left; { action: nil }
159
222
  when :right_arrow, :ctrl_f then cursor_right; { action: nil }
160
223
  when :up_arrow then handle_up_arrow
@@ -168,10 +231,15 @@ module Clacky
168
231
  when :ctrl_d then handle_ctrl_d
169
232
  when :ctrl_v then handle_paste
170
233
  when :shift_tab then { action: :toggle_mode }
171
- when :escape then { action: nil }
234
+ when :escape
235
+ if @command_suggestions.visible
236
+ @command_suggestions.hide
237
+ end
238
+ { action: nil }
172
239
  else
173
240
  if key.is_a?(String) && key.length >= 1 && key.ord >= 32
174
241
  insert_char(key)
242
+ update_command_suggestions
175
243
  end
176
244
  { action: nil }
177
245
  end
@@ -219,6 +287,13 @@ module Clacky
219
287
  render_separator(current_row)
220
288
  current_row += 1
221
289
 
290
+ # Command suggestions (rendered above tips)
291
+ if @command_suggestions && @command_suggestions.visible
292
+ # Render suggestions at current row
293
+ print @command_suggestions.render(row: current_row, col: 0, width: [@width - 4, 60].min)
294
+ current_row += @command_suggestions.required_height
295
+ end
296
+
222
297
  # Tips bar (if any)
223
298
  if @tips_message
224
299
  move_cursor(current_row, 0)
@@ -495,6 +570,7 @@ module Clacky
495
570
  @paste_counter = 0
496
571
  @paste_placeholders = {}
497
572
  clear_tips
573
+ @command_suggestions.hide if @command_suggestions
498
574
  end
499
575
 
500
576
  def submit
@@ -530,6 +606,23 @@ module Clacky
530
606
 
531
607
  private
532
608
 
609
+ # Update command suggestions based on current input
610
+ # Shows suggestions when input starts with /
611
+ private def update_command_suggestions
612
+ return unless @command_suggestions
613
+
614
+ current = current_line.strip
615
+
616
+ # Check if we should show suggestions (input starts with /)
617
+ if current.start_with?('/') && @line_index == 0
618
+ # Extract the filter text (everything after /)
619
+ filter_text = current[1..-1] || ""
620
+ @command_suggestions.show(filter_text)
621
+ else
622
+ @command_suggestions.hide
623
+ end
624
+ end
625
+
533
626
  # Render all input lines with auto-wrap support
534
627
  # @param start_row [Integer] Starting row position
535
628
  # @return [Integer] Next available row after rendering all lines
@@ -603,69 +696,14 @@ module Clacky
603
696
  # @param max_width [Integer] Maximum display width per wrapped line
604
697
  # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
605
698
  def wrap_line(line, max_width)
606
- return [{ text: "", start: 0, end: 0 }] if line.empty?
607
- return [{ text: line, start: 0, end: line.length }] if max_width <= 0
608
-
609
- segments = []
610
- chars = line.chars
611
- segment_start = 0
612
- current_width = 0
613
- current_end = 0
614
-
615
- chars.each_with_index do |char, idx|
616
- char_width = char_display_width(char)
617
-
618
- # If adding this character exceeds max width, complete current segment
619
- if current_width + char_width > max_width && current_end > segment_start
620
- segments << {
621
- text: chars[segment_start...current_end].join,
622
- start: segment_start,
623
- end: current_end
624
- }
625
- segment_start = idx
626
- current_end = idx + 1
627
- current_width = char_width
628
- else
629
- current_end = idx + 1
630
- current_width += char_width
631
- end
632
- end
633
-
634
- # Add the last segment
635
- if current_end > segment_start
636
- segments << {
637
- text: chars[segment_start...current_end].join,
638
- start: segment_start,
639
- end: current_end
640
- }
641
- end
642
-
643
- segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
699
+ super(line, max_width)
644
700
  end
645
701
 
646
702
  # Calculate display width of a single character
647
703
  # @param char [String] Single character
648
704
  # @return [Integer] Display width (1 or 2)
649
705
  def char_display_width(char)
650
- code = char.ord
651
- # East Asian Wide and Fullwidth characters take 2 columns
652
- if (code >= 0x1100 && code <= 0x115F) ||
653
- (code >= 0x2329 && code <= 0x232A) ||
654
- (code >= 0x2E80 && code <= 0x303E) ||
655
- (code >= 0x3040 && code <= 0xA4CF) ||
656
- (code >= 0xAC00 && code <= 0xD7A3) ||
657
- (code >= 0xF900 && code <= 0xFAFF) ||
658
- (code >= 0xFE10 && code <= 0xFE19) ||
659
- (code >= 0xFE30 && code <= 0xFE6F) ||
660
- (code >= 0xFF00 && code <= 0xFF60) ||
661
- (code >= 0xFFE0 && code <= 0xFFE6) ||
662
- (code >= 0x1F300 && code <= 0x1F9FF) ||
663
- (code >= 0x20000 && code <= 0x2FFFD) ||
664
- (code >= 0x30000 && code <= 0x3FFFD)
665
- 2
666
- else
667
- 1
668
- end
706
+ super(char)
669
707
  end
670
708
 
671
709
  # Strip ANSI escape codes from a string
@@ -714,7 +752,7 @@ module Clacky
714
752
  if text.start_with?('/')
715
753
  # Check if it's a command (single slash followed by English letters only)
716
754
  # Paths like /xxx/xxxx should not be treated as commands
717
- if text =~ /^\/([a-zA-Z]+)$/
755
+ if text =~ /^\/([a-zA-Z-]+)$/
718
756
  case text
719
757
  when '/clear'
720
758
  clear
@@ -724,8 +762,8 @@ module Clacky
724
762
  when '/exit', '/quit'
725
763
  return { action: :exit }
726
764
  else
727
- set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
728
- return { action: nil }
765
+ # Let other commands (like skills) pass through to agent
766
+ # Fall through to submit
729
767
  end
730
768
  end
731
769
  # If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
@@ -979,26 +1017,10 @@ module Clacky
979
1017
  # @param segment_end [Integer] End position of segment in line (char index)
980
1018
  # @return [String] Rendered segment with cursor if applicable
981
1019
  def render_line_segment_with_cursor(line, segment_start, segment_end)
982
- chars = line.chars
983
- segment_chars = chars[segment_start...segment_end]
984
-
985
- # Check if cursor is in this segment
986
- if @cursor_position >= segment_start && @cursor_position < segment_end
987
- # Cursor is in this segment
988
- cursor_pos_in_segment = @cursor_position - segment_start
989
- before_cursor = segment_chars[0...cursor_pos_in_segment].join
990
- cursor_char = segment_chars[cursor_pos_in_segment] || " "
991
- after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
992
-
993
- "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
994
- elsif @cursor_position == segment_end && segment_end == line.length
995
- # Cursor is at the very end of the line, show it in last segment
996
- segment_text = segment_chars.join
997
- "#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
998
- else
999
- # Cursor is not in this segment, just format normally
1000
- theme.format_text(segment_chars.join, :user)
1001
- end
1020
+ # Delegate to LineEditor's shared implementation
1021
+ rendered = super(line, segment_start, segment_end)
1022
+ # Apply theme colors for InputArea
1023
+ theme.format_text(rendered, :user)
1002
1024
  end
1003
1025
 
1004
1026
  # Render a separator line (ensures it doesn't exceed screen width)
@@ -14,6 +14,7 @@ module Clacky
14
14
  @todo_area = todo_area
15
15
  @render_mutex = Mutex.new
16
16
  @output_row = 0 # Track current output row position
17
+ @last_fixed_area_height = 0 # Track previous fixed area height to detect shrinkage
17
18
 
18
19
  calculate_layout
19
20
  setup_resize_handler
@@ -91,11 +92,19 @@ module Clacky
91
92
  def position_inline_input_cursor(inline_input)
92
93
  return unless inline_input
93
94
 
94
- # Calculate the actual terminal cursor position considering multi-byte characters
95
- # InlineInput is on the last output line (@output_row - 1)
96
- cursor_row = @output_row - 1
97
- cursor_col = inline_input.cursor_col # This already considers display width
98
-
95
+ # Use the shared method from LineEditor to calculate cursor position with wrap
96
+ prompt = inline_input.prompt
97
+ width = screen.width
98
+ wrap_row, wrap_col = inline_input.cursor_position_with_wrap(prompt, width)
99
+
100
+ # Get the number of lines InlineInput occupies (considering wrapping)
101
+ line_count = inline_input.line_count(width)
102
+
103
+ # InlineInput starts at @output_row - line_count
104
+ # Cursor is at wrap_row within that
105
+ cursor_row = @output_row - line_count + wrap_row
106
+ cursor_col = wrap_col
107
+
99
108
  # Move terminal cursor to the correct position
100
109
  screen.move_cursor(cursor_row, cursor_col)
101
110
  screen.flush
@@ -184,11 +193,11 @@ module Clacky
184
193
 
185
194
  @render_mutex.synchronize do
186
195
  lines = content.split("\n", -1) # -1 to keep trailing empty strings
187
-
196
+
188
197
  lines.each_with_index do |line, index|
189
198
  # Wrap long lines to prevent display issues
190
199
  wrapped_lines = wrap_long_line(line)
191
-
200
+
192
201
  wrapped_lines.each do |wrapped_line|
193
202
  write_output_line(wrapped_line)
194
203
  end
@@ -200,40 +209,72 @@ module Clacky
200
209
  end
201
210
  end
202
211
 
203
- # Update the last line in output area (for progress indicator)
204
- # @param content [String] Content to update
205
- def update_last_line(content)
212
+ # Update the last N lines in output area (for inline input updates)
213
+ # @param content [String] Content to update (may contain newlines for wrapped lines)
214
+ # @param old_line_count [Integer] Number of lines currently occupied (for clearing)
215
+ def update_last_line(content, old_line_count = 1)
206
216
  @render_mutex.synchronize do
207
217
  return if @output_row == 0 # No output yet
208
-
209
- # Last written line is at @output_row - 1
210
- last_row = @output_row - 1
211
- screen.move_cursor(last_row, 0)
212
- screen.clear_line
213
- print content
214
-
215
- # Hide terminal cursor to avoid showing two cursors
216
- # InlineInput uses visual cursor (white background) which is better for multi-byte chars
217
- screen.hide_cursor
218
+
219
+ # Calculate start row (last N lines)
220
+ start_row = @output_row - old_line_count
221
+ start_row = 0 if start_row < 0
222
+
223
+ # Clear all lines that will be updated
224
+ (start_row...@output_row).each do |row|
225
+ screen.move_cursor(row, 0)
226
+ screen.clear_line
227
+ end
228
+
229
+ # Re-render the content
230
+ lines = content.split("\n", -1)
231
+ current_row = start_row
232
+
233
+ lines.each_with_index do |line, idx|
234
+ screen.move_cursor(current_row, 0)
235
+ print line
236
+ current_row += 1
237
+ end
238
+
239
+ # Update output_row to new line count
240
+ @output_row = start_row + lines.length
241
+
242
+ # Clear any remaining old lines if new content has fewer lines
243
+ # This handles the case where content shrinks (e.g., delete from 2 lines to 1 line)
244
+ old_end_row = @output_row + (old_line_count - lines.length)
245
+ if old_end_row > @output_row && old_end_row <= start_row + old_line_count
246
+ # Clear the extra old lines
247
+ (@output_row...old_end_row).each do |row|
248
+ screen.move_cursor(row, 0)
249
+ screen.clear_line
250
+ end
251
+ end
252
+
253
+ # Re-render fixed areas to restore cursor position in input area
254
+ render_fixed_areas
218
255
  screen.flush
219
-
220
- # Don't re-render fixed areas - we're just updating existing content
221
256
  end
222
257
  end
223
258
 
224
- # Remove the last line from output area
225
- def remove_last_line
259
+ # Remove the last N lines from output area
260
+ # @param line_count [Integer] Number of lines to remove (default: 1)
261
+ def remove_last_line(line_count = 1)
226
262
  @render_mutex.synchronize do
227
263
  return if @output_row == 0 # No output to remove
228
-
229
- # Clear the last written line
230
- last_row = @output_row - 1
231
- screen.move_cursor(last_row, 0)
232
- screen.clear_line
233
-
234
- # Move output row back
235
- @output_row = last_row
236
-
264
+
265
+ # Calculate start row for removal
266
+ start_row = @output_row - line_count
267
+ start_row = 0 if start_row < 0
268
+
269
+ # Clear all lines being removed
270
+ (start_row...@output_row).each do |row|
271
+ screen.move_cursor(row, 0)
272
+ screen.clear_line
273
+ end
274
+
275
+ # Update output_row
276
+ @output_row = start_row
277
+
237
278
  # Re-render fixed areas to ensure consistency
238
279
  render_fixed_areas
239
280
  screen.flush
@@ -416,11 +457,26 @@ module Clacky
416
457
  # The InlineInput is rendered inline with output
417
458
  return if input_area.paused?
418
459
 
460
+ current_fixed_height = fixed_area_height
419
461
  start_row = fixed_area_start_row
420
462
  gap_row = start_row
421
463
  todo_row = gap_row + 1
422
464
  input_row = todo_row + (@todo_area&.height || 0)
423
465
 
466
+ # If fixed area shrank, clear the extra lines at the top to remove residual content
467
+ if @last_fixed_area_height > current_fixed_height
468
+ height_diff = @last_fixed_area_height - current_fixed_height
469
+ old_start_row = screen.height - @last_fixed_area_height
470
+ # Clear the extra lines that are no longer part of fixed area
471
+ (old_start_row...(old_start_row + height_diff)).each do |row|
472
+ screen.move_cursor(row, 0)
473
+ screen.clear_line
474
+ end
475
+ end
476
+
477
+ # Update last height for next comparison
478
+ @last_fixed_area_height = current_fixed_height
479
+
424
480
  # Render gap line
425
481
  screen.move_cursor(gap_row, 0)
426
482
  screen.clear_line
@@ -188,14 +188,154 @@ module Clacky
188
188
  # Strip ANSI codes from prompt to get actual display width
189
189
  visible_prompt = strip_ansi_codes(prompt)
190
190
  prompt_display_width = calculate_display_width(visible_prompt)
191
-
191
+
192
192
  # Calculate display width of text before cursor
193
193
  chars = @line.chars
194
194
  text_before_cursor = chars[0...@cursor_position].join
195
195
  text_display_width = calculate_display_width(text_before_cursor)
196
-
196
+
197
197
  prompt_display_width + text_display_width
198
198
  end
199
+
200
+ # Get cursor position considering line wrapping
201
+ # @param prompt [String] Prompt string before the line (may contain ANSI codes)
202
+ # @param width [Integer] Terminal width for wrapping
203
+ # @return [Array<Integer>] Row and column position (0-indexed)
204
+ def cursor_position_with_wrap(prompt = "", width = TTY::Screen.width)
205
+ return [0, cursor_column(prompt)] if width <= 0
206
+
207
+ prompt_width = calculate_display_width(strip_ansi_codes(prompt))
208
+ available_width = width - prompt_width
209
+
210
+ # Get wrapped segments for current line
211
+ wrapped_segments = wrap_line(@line, available_width)
212
+
213
+ # Find which segment contains cursor
214
+ cursor_segment_idx = 0
215
+ cursor_pos_in_segment = @cursor_position
216
+
217
+ wrapped_segments.each_with_index do |segment, idx|
218
+ if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
219
+ cursor_segment_idx = idx
220
+ cursor_pos_in_segment = @cursor_position - segment[:start]
221
+ break
222
+ elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
223
+ cursor_segment_idx = idx
224
+ cursor_pos_in_segment = segment[:end] - segment[:start]
225
+ break
226
+ end
227
+ end
228
+
229
+ # Calculate display width of text before cursor in this segment
230
+ chars = @line.chars
231
+ segment_start = wrapped_segments[cursor_segment_idx][:start]
232
+ text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
233
+ display_width = calculate_display_width(text_in_segment_before_cursor)
234
+
235
+ col = prompt_width + display_width
236
+ row = cursor_segment_idx
237
+
238
+ [row, col]
239
+ end
240
+
241
+ # Wrap a line into multiple segments based on available width
242
+ # Considers display width of characters (multi-byte characters like Chinese)
243
+ # @param line [String] The line to wrap
244
+ # @param max_width [Integer] Maximum display width per wrapped line
245
+ # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
246
+ def wrap_line(line, max_width)
247
+ return [{ text: "", start: 0, end: 0 }] if line.empty?
248
+ return [{ text: line, start: 0, end: line.length }] if max_width <= 0
249
+
250
+ segments = []
251
+ chars = line.chars
252
+ segment_start = 0
253
+ current_width = 0
254
+ current_end = 0
255
+
256
+ chars.each_with_index do |char, idx|
257
+ char_width = char_display_width(char)
258
+
259
+ # If adding this character exceeds max width, complete current segment
260
+ if current_width + char_width > max_width && current_end > segment_start
261
+ segments << {
262
+ text: chars[segment_start...current_end].join,
263
+ start: segment_start,
264
+ end: current_end
265
+ }
266
+ segment_start = idx
267
+ current_end = idx + 1
268
+ current_width = char_width
269
+ else
270
+ current_end = idx + 1
271
+ current_width += char_width
272
+ end
273
+ end
274
+
275
+ # Add the last segment
276
+ if current_end > segment_start
277
+ segments << {
278
+ text: chars[segment_start...current_end].join,
279
+ start: segment_start,
280
+ end: current_end
281
+ }
282
+ end
283
+
284
+ segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
285
+ end
286
+
287
+ # Calculate display width of a single character
288
+ # @param char [String] Single character
289
+ # @return [Integer] Display width (1 or 2)
290
+ def char_display_width(char)
291
+ code = char.ord
292
+ # East Asian Wide and Fullwidth characters take 2 columns
293
+ if (code >= 0x1100 && code <= 0x115F) ||
294
+ (code >= 0x2329 && code <= 0x232A) ||
295
+ (code >= 0x2E80 && code <= 0x303E) ||
296
+ (code >= 0x3040 && code <= 0xA4CF) ||
297
+ (code >= 0xAC00 && code <= 0xD7A3) ||
298
+ (code >= 0xF900 && code <= 0xFAFF) ||
299
+ (code >= 0xFE10 && code <= 0xFE19) ||
300
+ (code >= 0xFE30 && code <= 0xFE6F) ||
301
+ (code >= 0xFF00 && code <= 0xFF60) ||
302
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
303
+ (code >= 0x1F300 && code <= 0x1F9FF) ||
304
+ (code >= 0x20000 && code <= 0x2FFFD) ||
305
+ (code >= 0x30000 && code <= 0x3FFFD)
306
+ 2
307
+ else
308
+ 1
309
+ end
310
+ end
311
+
312
+ # Render a segment of a line with cursor if cursor is in this segment
313
+ # @param line [String] Full line text
314
+ # @param segment_start [Integer] Start position of segment in line (char index)
315
+ # @param segment_end [Integer] End position of segment in line (char index)
316
+ # @return [String] Rendered segment with cursor if applicable
317
+ def render_line_segment_with_cursor(line, segment_start, segment_end)
318
+ chars = line.chars
319
+ segment_chars = chars[segment_start...segment_end]
320
+
321
+ # Check if cursor is in this segment
322
+ if @cursor_position >= segment_start && @cursor_position < segment_end
323
+ # Cursor is in this segment
324
+ cursor_pos_in_segment = @cursor_position - segment_start
325
+ before_cursor = segment_chars[0...cursor_pos_in_segment].join
326
+ cursor_char = segment_chars[cursor_pos_in_segment] || " "
327
+ after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
328
+
329
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
330
+ elsif @cursor_position == segment_end && segment_end == line.length
331
+ # Cursor is at the very end of the line, show it in last segment
332
+ segment_text = segment_chars.join
333
+ "#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
334
+ else
335
+ # Cursor is not in this segment, just format normally
336
+ @pastel.white(segment_chars.join)
337
+ end
338
+ end
199
339
  end
200
340
  end
201
341
  end