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.
- checksums.yaml +4 -4
- data/lib/clacky/agent.rb +542 -54
- data/lib/clacky/cli.rb +341 -2
- data/lib/clacky/default_skills/skill-add/SKILL.md +66 -0
- data/lib/clacky/skill.rb +236 -0
- data/lib/clacky/skill_loader.rb +320 -0
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/grep.rb +9 -14
- data/lib/clacky/tools/safe_shell.rb +14 -8
- data/lib/clacky/tools/shell.rb +89 -52
- data/lib/clacky/tools/web_fetch.rb +81 -18
- data/lib/clacky/ui2/components/command_suggestions.rb +273 -0
- data/lib/clacky/ui2/components/inline_input.rb +34 -15
- data/lib/clacky/ui2/components/input_area.rb +105 -83
- data/lib/clacky/ui2/layout_manager.rb +89 -33
- data/lib/clacky/ui2/line_editor.rb +142 -2
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +34 -43
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -0
- metadata +5 -1
|
@@ -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
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
#
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
204
|
-
# @param content [String] Content to update
|
|
205
|
-
|
|
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
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
225
|
-
|
|
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
|
-
#
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|