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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/docs/ui2-architecture.md +124 -0
- data/lib/clacky/agent.rb +245 -340
- data/lib/clacky/agent_config.rb +1 -7
- data/lib/clacky/cli.rb +156 -397
- data/lib/clacky/client.rb +68 -36
- data/lib/clacky/gitignore_parser.rb +26 -12
- data/lib/clacky/model_pricing.rb +6 -2
- data/lib/clacky/session_manager.rb +6 -2
- data/lib/clacky/tools/glob.rb +65 -9
- data/lib/clacky/tools/grep.rb +4 -120
- data/lib/clacky/tools/run_project.rb +5 -0
- data/lib/clacky/tools/safe_shell.rb +49 -13
- data/lib/clacky/tools/shell.rb +1 -49
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +38 -26
- data/lib/clacky/ui2/README.md +214 -0
- data/lib/clacky/ui2/components/base_component.rb +163 -0
- data/lib/clacky/ui2/components/common_component.rb +89 -0
- data/lib/clacky/ui2/components/inline_input.rb +187 -0
- data/lib/clacky/ui2/components/input_area.rb +1029 -0
- data/lib/clacky/ui2/components/message_component.rb +76 -0
- data/lib/clacky/ui2/components/output_area.rb +112 -0
- data/lib/clacky/ui2/components/todo_area.rb +137 -0
- data/lib/clacky/ui2/components/tool_component.rb +106 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +93 -0
- data/lib/clacky/ui2/layout_manager.rb +331 -0
- data/lib/clacky/ui2/line_editor.rb +201 -0
- data/lib/clacky/ui2/screen_buffer.rb +238 -0
- data/lib/clacky/ui2/theme_manager.rb +68 -0
- data/lib/clacky/ui2/themes/base_theme.rb +99 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +56 -0
- data/lib/clacky/ui2/themes/minimal_theme.rb +50 -0
- data/lib/clacky/ui2/ui_controller.rb +720 -0
- data/lib/clacky/ui2/view_renderer.rb +160 -0
- data/lib/clacky/ui2.rb +37 -0
- data/lib/clacky/utils/file_ignore_helper.rb +126 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -6
- metadata +38 -6
- data/lib/clacky/ui/banner.rb +0 -155
- data/lib/clacky/ui/enhanced_prompt.rb +0 -786
- data/lib/clacky/ui/formatter.rb +0 -209
- data/lib/clacky/ui/statusbar.rb +0 -96
|
@@ -0,0 +1,331 @@
|
|
|
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? || content.empty?
|
|
181
|
+
|
|
182
|
+
@render_mutex.synchronize do
|
|
183
|
+
max_output_row = fixed_area_start_row - 1
|
|
184
|
+
|
|
185
|
+
content.split("\n").each do |line|
|
|
186
|
+
# If at max row, need to scroll before outputting
|
|
187
|
+
if @output_row > max_output_row
|
|
188
|
+
# Move to bottom of screen and print newline to trigger scroll
|
|
189
|
+
screen.move_cursor(screen.height - 1, 0)
|
|
190
|
+
print "\n"
|
|
191
|
+
# Stay at max_output_row for next output
|
|
192
|
+
@output_row = max_output_row
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Output line at current position
|
|
196
|
+
screen.move_cursor(@output_row, 0)
|
|
197
|
+
screen.clear_line
|
|
198
|
+
output_area.append(line)
|
|
199
|
+
@output_row += 1
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Re-render fixed areas at screen bottom
|
|
203
|
+
render_fixed_areas
|
|
204
|
+
screen.flush
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Update the last line in output area (for progress indicator)
|
|
209
|
+
# @param content [String] Content to update
|
|
210
|
+
def update_last_line(content)
|
|
211
|
+
@render_mutex.synchronize do
|
|
212
|
+
# Last output line is at @output_row - 1
|
|
213
|
+
last_row = [@output_row - 1, 0].max
|
|
214
|
+
screen.move_cursor(last_row, 0)
|
|
215
|
+
screen.clear_line
|
|
216
|
+
output_area.append(content)
|
|
217
|
+
render_fixed_areas
|
|
218
|
+
screen.flush
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Remove the last line from output area
|
|
223
|
+
def remove_last_line
|
|
224
|
+
@render_mutex.synchronize do
|
|
225
|
+
last_row = [@output_row - 1, 0].max
|
|
226
|
+
screen.move_cursor(last_row, 0)
|
|
227
|
+
screen.clear_line
|
|
228
|
+
@output_row = last_row if @output_row > 0
|
|
229
|
+
render_fixed_areas
|
|
230
|
+
screen.flush
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Scroll output area up
|
|
235
|
+
# @param lines [Integer] Number of lines to scroll
|
|
236
|
+
def scroll_output_up(lines = 1)
|
|
237
|
+
output_area.scroll_up(lines)
|
|
238
|
+
render_output
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Scroll output area down
|
|
242
|
+
# @param lines [Integer] Number of lines to scroll
|
|
243
|
+
def scroll_output_down(lines = 1)
|
|
244
|
+
output_area.scroll_down(lines)
|
|
245
|
+
render_output
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Handle window resize
|
|
249
|
+
def handle_resize
|
|
250
|
+
old_gap_row = @gap_row
|
|
251
|
+
|
|
252
|
+
screen.update_dimensions
|
|
253
|
+
calculate_layout
|
|
254
|
+
|
|
255
|
+
# Adjust output_row if it exceeds new max
|
|
256
|
+
max_row = fixed_area_start_row - 1
|
|
257
|
+
@output_row = [@output_row, max_row].min
|
|
258
|
+
|
|
259
|
+
# Clear old fixed area lines
|
|
260
|
+
([old_gap_row, 0].max...screen.height).each do |row|
|
|
261
|
+
screen.move_cursor(row, 0)
|
|
262
|
+
screen.clear_line
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
render_fixed_areas
|
|
266
|
+
screen.flush
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
# Calculate fixed area height (gap + todo + input)
|
|
272
|
+
def fixed_area_height
|
|
273
|
+
todo_height = @todo_area&.height || 0
|
|
274
|
+
input_height = @input_area.required_height
|
|
275
|
+
1 + todo_height + input_height # gap + todo + input
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Calculate the starting row for fixed areas (from screen bottom)
|
|
279
|
+
def fixed_area_start_row
|
|
280
|
+
screen.height - fixed_area_height
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Render fixed areas (gap, todo, input) at screen bottom
|
|
284
|
+
def render_fixed_areas
|
|
285
|
+
# When input is paused (InlineInput active), don't render fixed areas
|
|
286
|
+
# The InlineInput is rendered inline with output
|
|
287
|
+
return if input_area.paused?
|
|
288
|
+
|
|
289
|
+
start_row = fixed_area_start_row
|
|
290
|
+
gap_row = start_row
|
|
291
|
+
todo_row = gap_row + 1
|
|
292
|
+
input_row = todo_row + (@todo_area&.height || 0)
|
|
293
|
+
|
|
294
|
+
# Render gap line
|
|
295
|
+
screen.move_cursor(gap_row, 0)
|
|
296
|
+
screen.clear_line
|
|
297
|
+
|
|
298
|
+
# Render todo
|
|
299
|
+
if @todo_area&.visible?
|
|
300
|
+
@todo_area.render(start_row: todo_row)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Render input (InputArea renders its own visual cursor via render_line_with_cursor)
|
|
304
|
+
input_area.render(start_row: input_row, width: screen.width)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Internal render all (without mutex)
|
|
308
|
+
def render_all_internal
|
|
309
|
+
output_area.render(start_row: 0)
|
|
310
|
+
render_fixed_areas
|
|
311
|
+
screen.flush
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Restore cursor to input area
|
|
315
|
+
def restore_cursor_to_input
|
|
316
|
+
input_row = fixed_area_start_row + 1 + (@todo_area&.height || 0)
|
|
317
|
+
input_area.position_cursor(input_row)
|
|
318
|
+
screen.show_cursor
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Setup handler for window resize
|
|
322
|
+
def setup_resize_handler
|
|
323
|
+
Signal.trap("WINCH") do
|
|
324
|
+
handle_resize
|
|
325
|
+
end
|
|
326
|
+
rescue ArgumentError
|
|
327
|
+
# Signal already trapped, ignore
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
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
|