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,437 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "screen_buffer"
4
+
5
+ module Clacky
6
+ module UI2
7
+ # LayoutManager manages screen layout with split areas (output area on top, input area on bottom)
8
+ class LayoutManager
9
+ attr_reader :screen, :output_area, :input_area, :todo_area
10
+
11
+ def initialize(output_area:, input_area:, todo_area: nil)
12
+ @screen = ScreenBuffer.new
13
+ @output_area = output_area
14
+ @input_area = input_area
15
+ @todo_area = todo_area
16
+ @render_mutex = Mutex.new
17
+ @output_row = 0 # Track current output row position
18
+
19
+ calculate_layout
20
+ setup_resize_handler
21
+ end
22
+
23
+ # Calculate layout dimensions based on screen size and component heights
24
+ def calculate_layout
25
+ todo_height = @todo_area&.height || 0
26
+ input_height = @input_area.required_height
27
+ gap_height = 1 # Blank line between output and input
28
+
29
+ # Layout: output -> gap -> todo -> input (with its own separators and status)
30
+ @output_height = screen.height - gap_height - todo_height - input_height
31
+ @output_height = [1, @output_height].max # Minimum 1 line for output
32
+
33
+ @gap_row = @output_height
34
+ @todo_row = @gap_row + gap_height
35
+ @input_row = @todo_row + todo_height
36
+
37
+ # Update component dimensions
38
+ @output_area.height = @output_height
39
+ @input_area.row = @input_row
40
+ end
41
+
42
+ # Recalculate layout (called when input height changes)
43
+ def recalculate_layout
44
+ @render_mutex.synchronize do
45
+ # Save old layout values before recalculating
46
+ old_gap_row = @gap_row # This is the old fixed_area_start
47
+ old_input_row = @input_row
48
+
49
+ calculate_layout
50
+
51
+ # If layout changed, clear old fixed area and re-render at new position
52
+ if @input_row != old_input_row
53
+ # Clear old fixed area lines (from old gap_row to screen bottom)
54
+ ([old_gap_row, 0].max...screen.height).each do |row|
55
+ screen.move_cursor(row, 0)
56
+ screen.clear_line
57
+ end
58
+
59
+ # Re-render fixed areas at new position
60
+ render_fixed_areas
61
+ screen.flush
62
+ end
63
+ end
64
+ end
65
+
66
+ # Render all layout areas
67
+ def render_all
68
+ @render_mutex.synchronize do
69
+ render_all_internal
70
+ end
71
+ end
72
+
73
+ # Render output area - with native scroll, just ensure input stays in place
74
+ def render_output
75
+ @render_mutex.synchronize do
76
+ # Output is written directly, just need to re-render fixed areas
77
+ render_fixed_areas
78
+ screen.flush
79
+ end
80
+ end
81
+
82
+ # Render just the input area
83
+ def render_input
84
+ @render_mutex.synchronize do
85
+ # Clear and re-render entire fixed area to ensure consistency
86
+ render_fixed_areas
87
+ screen.flush
88
+ end
89
+ end
90
+
91
+ # Position cursor for inline input in output area
92
+ # @param inline_input [Components::InlineInput] InlineInput component
93
+ def position_inline_input_cursor(inline_input)
94
+ return unless inline_input
95
+
96
+ # InlineInput renders its own visual cursor via render_line_with_cursor
97
+ # (white background on cursor character), so we don't need terminal cursor.
98
+ # Just hide the terminal cursor to avoid showing two cursors.
99
+ screen.hide_cursor
100
+ screen.flush
101
+ end
102
+
103
+ # Update todos and re-render
104
+ # @param todos [Array<Hash>] Array of todo items
105
+ def update_todos(todos)
106
+ return unless @todo_area
107
+
108
+ @render_mutex.synchronize do
109
+ old_height = @todo_area.height
110
+ old_gap_row = @gap_row
111
+
112
+ @todo_area.update(todos)
113
+ new_height = @todo_area.height
114
+
115
+ # Recalculate layout if height changed
116
+ if old_height != new_height
117
+ calculate_layout
118
+
119
+ # Clear old fixed area lines (from old gap_row to screen bottom)
120
+ ([old_gap_row, 0].max...screen.height).each do |row|
121
+ screen.move_cursor(row, 0)
122
+ screen.clear_line
123
+ end
124
+ end
125
+
126
+ # Render fixed areas at new position
127
+ render_fixed_areas
128
+ screen.flush
129
+ end
130
+ end
131
+
132
+ # Initialize the screen (render initial content)
133
+ def initialize_screen
134
+ screen.clear_screen
135
+ screen.hide_cursor
136
+ @output_row = 0
137
+ render_all
138
+ end
139
+
140
+ # Cleanup the screen (restore cursor)
141
+ def cleanup_screen
142
+ @render_mutex.synchronize do
143
+ # Clear fixed areas (gap + todo + input)
144
+ fixed_start = fixed_area_start_row
145
+ (fixed_start...screen.height).each do |row|
146
+ screen.move_cursor(row, 0)
147
+ screen.clear_line
148
+ end
149
+
150
+ # Move cursor to start of a new line after last output
151
+ # Use \r to ensure we're at column 0, then move down
152
+ screen.move_cursor([@output_row, 0].max, 0)
153
+ print "\r" # Carriage return to column 0
154
+ screen.show_cursor
155
+ screen.flush
156
+ end
157
+ end
158
+
159
+ # Clear output area (for /clear command)
160
+ def clear_output
161
+ @render_mutex.synchronize do
162
+ # Clear all lines in output area (from 0 to fixed_area_start - 1)
163
+ max_output_row = fixed_area_start_row
164
+ (0...max_output_row).each do |row|
165
+ screen.move_cursor(row, 0)
166
+ screen.clear_line
167
+ end
168
+ # Reset output row position to start
169
+ @output_row = 0
170
+ # Re-render fixed areas to ensure they stay in place
171
+ render_fixed_areas
172
+ screen.flush
173
+ end
174
+ end
175
+
176
+ # Append content to output area
177
+ # Track current row, scroll when reaching fixed area
178
+ # @param content [String] Content to append
179
+ def append_output(content)
180
+ return if content.nil?
181
+
182
+ @render_mutex.synchronize do
183
+ max_output_row = fixed_area_start_row - 1
184
+
185
+ # Special handling for empty string - just add a blank line
186
+ if content.empty?
187
+ print "\n"
188
+ @output_row += 1
189
+ render_fixed_areas
190
+ screen.flush
191
+ return
192
+ end
193
+
194
+ content.split("\n").each do |line|
195
+ # Wrap long lines to prevent display issues
196
+ wrapped_lines = wrap_long_line(line)
197
+
198
+ wrapped_lines.each do |wrapped_line|
199
+ # If at max row, need to scroll before outputting
200
+ if @output_row > max_output_row
201
+ # Move to bottom of screen and print newline to trigger scroll
202
+ screen.move_cursor(screen.height - 1, 0)
203
+ print "\n"
204
+ # Stay at max_output_row for next output
205
+ @output_row = max_output_row
206
+ end
207
+
208
+ # Output line at current position
209
+ screen.move_cursor(@output_row, 0)
210
+ screen.clear_line
211
+ output_area.append(wrapped_line)
212
+ @output_row += 1
213
+ end
214
+ end
215
+
216
+ # Re-render fixed areas at screen bottom
217
+ render_fixed_areas
218
+ screen.flush
219
+ end
220
+ end
221
+
222
+ # Update the last line in output area (for progress indicator)
223
+ # @param content [String] Content to update
224
+ def update_last_line(content)
225
+ @render_mutex.synchronize do
226
+ # Last output line is at @output_row - 1
227
+ last_row = [@output_row - 1, 0].max
228
+ screen.move_cursor(last_row, 0)
229
+ screen.clear_line
230
+ output_area.append(content)
231
+ render_fixed_areas
232
+ screen.flush
233
+ end
234
+ end
235
+
236
+ # Remove the last line from output area
237
+ def remove_last_line
238
+ @render_mutex.synchronize do
239
+ last_row = [@output_row - 1, 0].max
240
+ screen.move_cursor(last_row, 0)
241
+ screen.clear_line
242
+ @output_row = last_row if @output_row > 0
243
+ render_fixed_areas
244
+ screen.flush
245
+ end
246
+ end
247
+
248
+ # Scroll output area up
249
+ # @param lines [Integer] Number of lines to scroll
250
+ def scroll_output_up(lines = 1)
251
+ output_area.scroll_up(lines)
252
+ render_output
253
+ end
254
+
255
+ # Scroll output area down
256
+ # @param lines [Integer] Number of lines to scroll
257
+ def scroll_output_down(lines = 1)
258
+ output_area.scroll_down(lines)
259
+ render_output
260
+ end
261
+
262
+ # Handle window resize
263
+ def handle_resize
264
+ old_gap_row = @gap_row
265
+
266
+ screen.update_dimensions
267
+ calculate_layout
268
+
269
+ # Adjust output_row if it exceeds new max
270
+ max_row = fixed_area_start_row - 1
271
+ @output_row = [@output_row, max_row].min
272
+
273
+ # Clear old fixed area lines
274
+ ([old_gap_row, 0].max...screen.height).each do |row|
275
+ screen.move_cursor(row, 0)
276
+ screen.clear_line
277
+ end
278
+
279
+ render_fixed_areas
280
+ screen.flush
281
+ end
282
+
283
+ private
284
+
285
+ # Wrap a long line into multiple lines based on terminal width
286
+ # Considers display width of multi-byte characters (e.g., Chinese characters)
287
+ # @param line [String] Line to wrap
288
+ # @return [Array<String>] Array of wrapped lines
289
+ def wrap_long_line(line)
290
+ return [""] if line.nil? || line.empty?
291
+
292
+ max_width = screen.width
293
+ return [line] if max_width <= 0
294
+
295
+ # Strip ANSI codes for width calculation
296
+ visible_line = line.gsub(/\e\[[0-9;]*m/, '')
297
+
298
+ # Check if line needs wrapping
299
+ display_width = calculate_display_width(visible_line)
300
+ return [line] if display_width <= max_width
301
+
302
+ # Line needs wrapping - split by considering display width
303
+ wrapped = []
304
+ current_line = ""
305
+ current_width = 0
306
+ ansi_codes = [] # Track ANSI codes to carry over
307
+
308
+ # Extract ANSI codes and text segments
309
+ segments = line.split(/(\e\[[0-9;]*m)/)
310
+
311
+ segments.each do |segment|
312
+ if segment =~ /^\e\[[0-9;]*m$/
313
+ # ANSI code - add to current codes
314
+ ansi_codes << segment
315
+ current_line += segment
316
+ else
317
+ # Text segment - process character by character
318
+ segment.each_char do |char|
319
+ char_width = char_display_width(char)
320
+
321
+ if current_width + char_width > max_width && !current_line.empty?
322
+ # Complete current line
323
+ wrapped << current_line
324
+ # Start new line with carried-over ANSI codes
325
+ current_line = ansi_codes.join
326
+ current_width = 0
327
+ end
328
+
329
+ current_line += char
330
+ current_width += char_width
331
+ end
332
+ end
333
+ end
334
+
335
+ # Add remaining content
336
+ wrapped << current_line unless current_line.empty? || current_line == ansi_codes.join
337
+
338
+ wrapped.empty? ? [""] : wrapped
339
+ end
340
+
341
+ # Calculate display width of a single character
342
+ # @param char [String] Single character
343
+ # @return [Integer] Display width (1 or 2)
344
+ def char_display_width(char)
345
+ code = char.ord
346
+ # East Asian Wide and Fullwidth characters take 2 columns
347
+ if (code >= 0x1100 && code <= 0x115F) ||
348
+ (code >= 0x2329 && code <= 0x232A) ||
349
+ (code >= 0x2E80 && code <= 0x303E) ||
350
+ (code >= 0x3040 && code <= 0xA4CF) ||
351
+ (code >= 0xAC00 && code <= 0xD7A3) ||
352
+ (code >= 0xF900 && code <= 0xFAFF) ||
353
+ (code >= 0xFE10 && code <= 0xFE19) ||
354
+ (code >= 0xFE30 && code <= 0xFE6F) ||
355
+ (code >= 0xFF00 && code <= 0xFF60) ||
356
+ (code >= 0xFFE0 && code <= 0xFFE6) ||
357
+ (code >= 0x1F300 && code <= 0x1F9FF) ||
358
+ (code >= 0x20000 && code <= 0x2FFFD) ||
359
+ (code >= 0x30000 && code <= 0x3FFFD)
360
+ 2
361
+ else
362
+ 1
363
+ end
364
+ end
365
+
366
+ # Calculate display width of a string (considering multi-byte characters)
367
+ # @param text [String] Text to calculate
368
+ # @return [Integer] Display width
369
+ def calculate_display_width(text)
370
+ width = 0
371
+ text.each_char do |char|
372
+ width += char_display_width(char)
373
+ end
374
+ width
375
+ end
376
+
377
+ # Calculate fixed area height (gap + todo + input)
378
+ def fixed_area_height
379
+ todo_height = @todo_area&.height || 0
380
+ input_height = @input_area.required_height
381
+ 1 + todo_height + input_height # gap + todo + input
382
+ end
383
+
384
+ # Calculate the starting row for fixed areas (from screen bottom)
385
+ def fixed_area_start_row
386
+ screen.height - fixed_area_height
387
+ end
388
+
389
+ # Render fixed areas (gap, todo, input) at screen bottom
390
+ def render_fixed_areas
391
+ # When input is paused (InlineInput active), don't render fixed areas
392
+ # The InlineInput is rendered inline with output
393
+ return if input_area.paused?
394
+
395
+ start_row = fixed_area_start_row
396
+ gap_row = start_row
397
+ todo_row = gap_row + 1
398
+ input_row = todo_row + (@todo_area&.height || 0)
399
+
400
+ # Render gap line
401
+ screen.move_cursor(gap_row, 0)
402
+ screen.clear_line
403
+
404
+ # Render todo
405
+ if @todo_area&.visible?
406
+ @todo_area.render(start_row: todo_row)
407
+ end
408
+
409
+ # Render input (InputArea renders its own visual cursor via render_line_with_cursor)
410
+ input_area.render(start_row: input_row, width: screen.width)
411
+ end
412
+
413
+ # Internal render all (without mutex)
414
+ def render_all_internal
415
+ output_area.render(start_row: 0)
416
+ render_fixed_areas
417
+ screen.flush
418
+ end
419
+
420
+ # Restore cursor to input area
421
+ def restore_cursor_to_input
422
+ input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
423
+ input_area.position_cursor(input_row)
424
+ screen.show_cursor
425
+ end
426
+
427
+ # Setup handler for window resize
428
+ def setup_resize_handler
429
+ Signal.trap("WINCH") do
430
+ handle_resize
431
+ end
432
+ rescue ArgumentError
433
+ # Signal already trapped, ignore
434
+ end
435
+ end
436
+ end
437
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module Clacky
6
+ module UI2
7
+ # LineEditor module provides single-line text editing functionality
8
+ # Shared by InputArea and InlineInput components
9
+ module LineEditor
10
+ attr_reader :cursor_position
11
+
12
+ def initialize_line_editor
13
+ @line = ""
14
+ @cursor_position = 0
15
+ @pastel = Pastel.new
16
+ end
17
+
18
+ # Get current line content
19
+ def current_line
20
+ @line
21
+ end
22
+
23
+ # Set line content
24
+ def set_line(text)
25
+ @line = text
26
+ @cursor_position = [@cursor_position, @line.chars.length].min
27
+ end
28
+
29
+ # Clear line
30
+ def clear_line_content
31
+ @line = ""
32
+ @cursor_position = 0
33
+ end
34
+
35
+ # Insert character at cursor position
36
+ def insert_char(char)
37
+ chars = @line.chars
38
+ chars.insert(@cursor_position, char)
39
+ @line = chars.join
40
+ @cursor_position += 1
41
+ end
42
+
43
+ # Backspace - delete character before cursor
44
+ def backspace
45
+ return if @cursor_position == 0
46
+ chars = @line.chars
47
+ chars.delete_at(@cursor_position - 1)
48
+ @line = chars.join
49
+ @cursor_position -= 1
50
+ end
51
+
52
+ # Delete character at cursor position
53
+ def delete_char
54
+ chars = @line.chars
55
+ return if @cursor_position >= chars.length
56
+ chars.delete_at(@cursor_position)
57
+ @line = chars.join
58
+ end
59
+
60
+ # Move cursor left
61
+ def cursor_left
62
+ @cursor_position = [@cursor_position - 1, 0].max
63
+ end
64
+
65
+ # Move cursor right
66
+ def cursor_right
67
+ @cursor_position = [@cursor_position + 1, @line.chars.length].min
68
+ end
69
+
70
+ # Move cursor to start of line
71
+ def cursor_home
72
+ @cursor_position = 0
73
+ end
74
+
75
+ # Move cursor to end of line
76
+ def cursor_end
77
+ @cursor_position = @line.chars.length
78
+ end
79
+
80
+ # Kill from cursor to end of line (Ctrl+K)
81
+ def kill_to_end
82
+ chars = @line.chars
83
+ @line = chars[0...@cursor_position].join
84
+ end
85
+
86
+ # Kill from start to cursor (Ctrl+U)
87
+ def kill_to_start
88
+ chars = @line.chars
89
+ @line = chars[@cursor_position..-1]&.join || ""
90
+ @cursor_position = 0
91
+ end
92
+
93
+ # Kill word before cursor (Ctrl+W)
94
+ def kill_word
95
+ chars = @line.chars
96
+ pos = @cursor_position - 1
97
+
98
+ # Skip whitespace
99
+ while pos >= 0 && chars[pos] =~ /\s/
100
+ pos -= 1
101
+ end
102
+ # Delete word characters
103
+ while pos >= 0 && chars[pos] =~ /\S/
104
+ pos -= 1
105
+ end
106
+
107
+ delete_start = pos + 1
108
+ chars.slice!(delete_start...@cursor_position)
109
+ @line = chars.join
110
+ @cursor_position = delete_start
111
+ end
112
+
113
+ # Insert text at cursor position
114
+ def insert_text(text)
115
+ return if text.nil? || text.empty?
116
+ chars = @line.chars
117
+ text.chars.each_with_index do |c, i|
118
+ chars.insert(@cursor_position + i, c)
119
+ end
120
+ @line = chars.join
121
+ @cursor_position += text.length
122
+ end
123
+
124
+ # Expand placeholders and normalize line endings
125
+ def expand_placeholders(text, placeholders)
126
+ result = text.dup
127
+ placeholders.each do |placeholder, actual_content|
128
+ # Normalize line endings to \n
129
+ normalized_content = actual_content.gsub(/\r\n|\r/, "\n")
130
+ result.gsub!(placeholder, normalized_content)
131
+ end
132
+ result
133
+ end
134
+
135
+ # Render line with cursor highlight
136
+ # @return [String] Rendered line with cursor
137
+ def render_line_with_cursor
138
+ chars = @line.chars
139
+ before_cursor = chars[0...@cursor_position].join
140
+ cursor_char = chars[@cursor_position] || " "
141
+ after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
142
+
143
+ "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
144
+ end
145
+
146
+ # Calculate display width of a string, considering multi-byte characters
147
+ # East Asian Wide and Fullwidth characters (like Chinese) take 2 columns
148
+ # @param text [String] UTF-8 encoded text
149
+ # @return [Integer] Display width in terminal columns
150
+ def calculate_display_width(text)
151
+ width = 0
152
+ text.each_char do |char|
153
+ code = char.ord
154
+ # East Asian Wide and Fullwidth characters
155
+ # See: https://www.unicode.org/reports/tr11/
156
+ if (code >= 0x1100 && code <= 0x115F) || # Hangul Jamo
157
+ (code >= 0x2329 && code <= 0x232A) || # Left/Right-Pointing Angle Brackets
158
+ (code >= 0x2E80 && code <= 0x303E) || # CJK Radicals Supplement .. CJK Symbols and Punctuation
159
+ (code >= 0x3040 && code <= 0xA4CF) || # Hiragana .. Yi Radicals
160
+ (code >= 0xAC00 && code <= 0xD7A3) || # Hangul Syllables
161
+ (code >= 0xF900 && code <= 0xFAFF) || # CJK Compatibility Ideographs
162
+ (code >= 0xFE10 && code <= 0xFE19) || # Vertical Forms
163
+ (code >= 0xFE30 && code <= 0xFE6F) || # CJK Compatibility Forms .. Small Form Variants
164
+ (code >= 0xFF00 && code <= 0xFF60) || # Fullwidth Forms
165
+ (code >= 0xFFE0 && code <= 0xFFE6) || # Fullwidth Forms
166
+ (code >= 0x1F300 && code <= 0x1F9FF) || # Emoticons, Symbols, etc.
167
+ (code >= 0x20000 && code <= 0x2FFFD) || # CJK Unified Ideographs Extension B..F
168
+ (code >= 0x30000 && code <= 0x3FFFD) # CJK Unified Ideographs Extension G
169
+ width += 2
170
+ else
171
+ width += 1
172
+ end
173
+ end
174
+ width
175
+ end
176
+
177
+ # Strip ANSI escape codes from a string
178
+ # @param text [String] Text with ANSI codes
179
+ # @return [String] Text without ANSI codes
180
+ def strip_ansi_codes(text)
181
+ text.gsub(/\e\[[0-9;]*m/, '')
182
+ end
183
+
184
+ # Get cursor column position (considering multi-byte characters)
185
+ # @param prompt [String] Prompt string before the line (may contain ANSI codes)
186
+ # @return [Integer] Column position for cursor
187
+ def cursor_column(prompt = "")
188
+ # Strip ANSI codes from prompt to get actual display width
189
+ visible_prompt = strip_ansi_codes(prompt)
190
+ prompt_display_width = calculate_display_width(visible_prompt)
191
+
192
+ # Calculate display width of text before cursor
193
+ chars = @line.chars
194
+ text_before_cursor = chars[0...@cursor_position].join
195
+ text_display_width = calculate_display_width(text_before_cursor)
196
+
197
+ prompt_display_width + text_display_width
198
+ end
199
+ end
200
+ end
201
+ end