openclacky 0.6.1 → 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.
@@ -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
@@ -84,8 +89,15 @@ module Clacky
84
89
  # When paused (InlineInput active), don't take up any space
85
90
  return 0 if @paused
86
91
 
87
- height = 1 # Session bar (top)
88
- height += 1 # Separator after session bar
92
+ height = 0
93
+
94
+ # Session bar - calculate actual wrapped height
95
+ height += calculate_sessionbar_height
96
+
97
+ # Separator after session bar
98
+ height += 1
99
+
100
+ # Images
89
101
  height += @images.size
90
102
 
91
103
  # Calculate height considering wrapped lines
@@ -101,12 +113,26 @@ module Clacky
101
113
  height += wrapped_segments.size
102
114
  end
103
115
 
104
- height += 1 # Bottom separator
116
+ # Bottom separator
117
+ height += 1
118
+
119
+ # Command suggestions (rendered above input)
120
+ height += @command_suggestions.required_height if @command_suggestions
121
+
122
+ # Tips and user tips
105
123
  height += 1 if @tips_message
106
124
  height += 1 if @user_tip
125
+
107
126
  height
108
127
  end
109
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
+
110
136
  # Update session bar info
111
137
  # @param working_dir [String] Working directory
112
138
  # @param mode [String] Permission mode
@@ -133,17 +159,65 @@ module Clacky
133
159
 
134
160
  old_height = required_height
135
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
+
136
203
  result = case key
137
204
  when Hash
138
205
  if key[:type] == :rapid_input
139
206
  insert_text(key[:text])
140
207
  clear_tips
208
+ update_command_suggestions
141
209
  end
142
210
  { action: nil }
143
211
  when :enter then handle_enter
144
212
  when :newline then newline; { action: nil }
145
- when :backspace then backspace; { action: nil }
146
- 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 }
147
221
  when :left_arrow, :ctrl_b then cursor_left; { action: nil }
148
222
  when :right_arrow, :ctrl_f then cursor_right; { action: nil }
149
223
  when :up_arrow then handle_up_arrow
@@ -157,10 +231,15 @@ module Clacky
157
231
  when :ctrl_d then handle_ctrl_d
158
232
  when :ctrl_v then handle_paste
159
233
  when :shift_tab then { action: :toggle_mode }
160
- when :escape then { action: nil }
234
+ when :escape
235
+ if @command_suggestions.visible
236
+ @command_suggestions.hide
237
+ end
238
+ { action: nil }
161
239
  else
162
240
  if key.is_a?(String) && key.length >= 1 && key.ord >= 32
163
241
  insert_char(key)
242
+ update_command_suggestions
164
243
  end
165
244
  { action: nil }
166
245
  end
@@ -202,54 +281,19 @@ module Clacky
202
281
  end
203
282
 
204
283
  # Input lines with auto-wrap support
205
- @lines.each_with_index do |line, idx|
206
- prefix = if idx == 0
207
- prompt_text = theme.format_symbol(:user) + " "
208
- prompt_text
209
- else
210
- " " * prompt.length
211
- end
212
-
213
- # Calculate available width for text (excluding prefix)
214
- prefix_width = calculate_display_width(strip_ansi_codes(prefix))
215
- available_width = @width - prefix_width
216
-
217
- # Wrap line if needed
218
- wrapped_segments = wrap_line(line, available_width)
219
-
220
- wrapped_segments.each_with_index do |segment_info, wrap_idx|
221
- move_cursor(current_row, 0)
222
-
223
- segment_text = segment_info[:text]
224
- segment_start = segment_info[:start]
225
- segment_end = segment_info[:end]
226
-
227
- content = if wrap_idx == 0
228
- # First wrapped line includes prefix
229
- if idx == @line_index
230
- "#{prefix}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
231
- else
232
- "#{prefix}#{theme.format_text(segment_text, :user)}"
233
- end
234
- else
235
- # Continuation lines have indent matching prefix width
236
- continuation_indent = " " * prefix_width
237
- if idx == @line_index
238
- "#{continuation_indent}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
239
- else
240
- "#{continuation_indent}#{theme.format_text(segment_text, :user)}"
241
- end
242
- end
243
-
244
- print_with_padding(content)
245
- current_row += 1
246
- end
247
- end
284
+ current_row = render_input_lines(current_row)
248
285
 
249
286
  # Bottom separator
250
287
  render_separator(current_row)
251
288
  current_row += 1
252
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
+
253
297
  # Tips bar (if any)
254
298
  if @tips_message
255
299
  move_cursor(current_row, 0)
@@ -526,6 +570,7 @@ module Clacky
526
570
  @paste_counter = 0
527
571
  @paste_placeholders = {}
528
572
  clear_tips
573
+ @command_suggestions.hide if @command_suggestions
529
574
  end
530
575
 
531
576
  def submit
@@ -561,75 +606,104 @@ module Clacky
561
606
 
562
607
  private
563
608
 
564
- # Wrap a line into multiple segments based on available width
565
- # Considers display width of characters (multi-byte characters like Chinese)
566
- # @param line [String] The line to wrap
567
- # @param max_width [Integer] Maximum display width per wrapped line
568
- # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
569
- def wrap_line(line, max_width)
570
- return [{ text: "", start: 0, end: 0 }] if line.empty?
571
- return [{ text: line, start: 0, end: line.length }] if max_width <= 0
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
572
625
 
573
- segments = []
574
- chars = line.chars
575
- segment_start = 0
576
- current_width = 0
577
- current_end = 0
626
+ # Render all input lines with auto-wrap support
627
+ # @param start_row [Integer] Starting row position
628
+ # @return [Integer] Next available row after rendering all lines
629
+ def render_input_lines(start_row)
630
+ current_row = start_row
631
+
632
+ @lines.each_with_index do |line, line_idx|
633
+ prefix = calculate_line_prefix(line_idx)
634
+ prefix_width = calculate_display_width(strip_ansi_codes(prefix))
635
+ available_width = @width - prefix_width
636
+ wrapped_segments = wrap_line(line, available_width)
578
637
 
579
- chars.each_with_index do |char, idx|
580
- char_width = char_display_width(char)
581
-
582
- # If adding this character exceeds max width, complete current segment
583
- if current_width + char_width > max_width && current_end > segment_start
584
- segments << {
585
- text: chars[segment_start...current_end].join,
586
- start: segment_start,
587
- end: current_end
588
- }
589
- segment_start = idx
590
- current_end = idx + 1
591
- current_width = char_width
592
- else
593
- current_end = idx + 1
594
- current_width += char_width
638
+ wrapped_segments.each_with_index do |segment_info, wrap_idx|
639
+ content = render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
640
+ move_cursor(current_row, 0)
641
+ print_with_padding(content)
642
+ current_row += 1
595
643
  end
596
644
  end
645
+
646
+ current_row
647
+ end
597
648
 
598
- # Add the last segment
599
- if current_end > segment_start
600
- segments << {
601
- text: chars[segment_start...current_end].join,
602
- start: segment_start,
603
- end: current_end
604
- }
649
+ # Calculate the prefix (prompt or indent) for a given line index
650
+ # @param line_idx [Integer] Index of the line
651
+ # @return [String] Prefix string (with formatting)
652
+ private def calculate_line_prefix(line_idx)
653
+ if line_idx == 0
654
+ theme.format_symbol(:user) + " "
655
+ else
656
+ " " * prompt.length
605
657
  end
658
+ end
659
+
660
+ # Render a single segment of a line (handling cursor and wrapping)
661
+ # @param line [String] Full line text
662
+ # @param line_idx [Integer] Index of the line in @lines
663
+ # @param segment_info [Hash] Segment information from wrap_line
664
+ # @param wrap_idx [Integer] Index of this segment in wrapped segments
665
+ # @param prefix [String] Line prefix (prompt or indent)
666
+ # @param prefix_width [Integer] Display width of the prefix
667
+ # @return [String] Formatted content for this segment
668
+ private def render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
669
+ segment_text = segment_info[:text]
670
+ segment_start = segment_info[:start]
671
+ segment_end = segment_info[:end]
606
672
 
607
- segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
673
+ is_current_line = (line_idx == @line_index)
674
+ is_first_segment = (wrap_idx == 0)
675
+
676
+ # Determine the line prefix
677
+ line_prefix = if is_first_segment
678
+ prefix
679
+ else
680
+ " " * prefix_width # Continuation indent
681
+ end
682
+
683
+ # Render the segment content (with or without cursor)
684
+ segment_content = if is_current_line
685
+ render_line_segment_with_cursor(line, segment_start, segment_end)
686
+ else
687
+ theme.format_text(segment_text, :user)
688
+ end
689
+
690
+ "#{line_prefix}#{segment_content}"
691
+ end
692
+
693
+ # Wrap a line into multiple segments based on available width
694
+ # Considers display width of characters (multi-byte characters like Chinese)
695
+ # @param line [String] The line to wrap
696
+ # @param max_width [Integer] Maximum display width per wrapped line
697
+ # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
698
+ def wrap_line(line, max_width)
699
+ super(line, max_width)
608
700
  end
609
701
 
610
702
  # Calculate display width of a single character
611
703
  # @param char [String] Single character
612
704
  # @return [Integer] Display width (1 or 2)
613
705
  def char_display_width(char)
614
- code = char.ord
615
- # East Asian Wide and Fullwidth characters take 2 columns
616
- if (code >= 0x1100 && code <= 0x115F) ||
617
- (code >= 0x2329 && code <= 0x232A) ||
618
- (code >= 0x2E80 && code <= 0x303E) ||
619
- (code >= 0x3040 && code <= 0xA4CF) ||
620
- (code >= 0xAC00 && code <= 0xD7A3) ||
621
- (code >= 0xF900 && code <= 0xFAFF) ||
622
- (code >= 0xFE10 && code <= 0xFE19) ||
623
- (code >= 0xFE30 && code <= 0xFE6F) ||
624
- (code >= 0xFF00 && code <= 0xFF60) ||
625
- (code >= 0xFFE0 && code <= 0xFFE6) ||
626
- (code >= 0x1F300 && code <= 0x1F9FF) ||
627
- (code >= 0x20000 && code <= 0x2FFFD) ||
628
- (code >= 0x30000 && code <= 0x3FFFD)
629
- 2
630
- else
631
- 1
632
- end
706
+ super(char)
633
707
  end
634
708
 
635
709
  # Strip ANSI escape codes from a string
@@ -646,12 +720,29 @@ module Clacky
646
720
  visible_content = content.gsub(/\e\[[0-9;]*m/, '')
647
721
  visible_width = calculate_display_width(visible_content)
648
722
 
649
- # Print content
650
- print content
651
-
652
- # Pad with spaces if needed to clear old content
653
- remaining = @width - visible_width
654
- print " " * remaining if remaining > 0
723
+ # IMPORTANT: If content exceeds screen width, truncate to prevent terminal auto-wrap
724
+ if visible_width > @width
725
+ # Content too long - truncate to fit (loses ANSI colors but prevents wrapping)
726
+ truncate_at = 0
727
+ current_width = 0
728
+ visible_content.each_char.with_index do |char, idx|
729
+ char_width = char_display_width(char)
730
+ break if current_width + char_width + 3 > @width # Reserve 3 for "..."
731
+ current_width += char_width
732
+ truncate_at = idx + 1
733
+ end
734
+ print visible_content[0...truncate_at]
735
+ print "..."
736
+ # Pad remaining
737
+ remaining = @width - current_width - 3
738
+ print " " * remaining if remaining > 0
739
+ else
740
+ # Content fits - print normally
741
+ print content
742
+ # Pad with spaces if needed to clear old content
743
+ remaining = @width - visible_width
744
+ print " " * remaining if remaining > 0
745
+ end
655
746
  end
656
747
 
657
748
  def handle_enter
@@ -661,7 +752,7 @@ module Clacky
661
752
  if text.start_with?('/')
662
753
  # Check if it's a command (single slash followed by English letters only)
663
754
  # Paths like /xxx/xxxx should not be treated as commands
664
- if text =~ /^\/([a-zA-Z]+)$/
755
+ if text =~ /^\/([a-zA-Z-]+)$/
665
756
  case text
666
757
  when '/clear'
667
758
  clear
@@ -671,8 +762,8 @@ module Clacky
671
762
  when '/exit', '/quit'
672
763
  return { action: :exit }
673
764
  else
674
- set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
675
- return { action: nil }
765
+ # Let other commands (like skills) pass through to agent
766
+ # Fall through to submit
676
767
  end
677
768
  end
678
769
  # If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
@@ -926,44 +1017,52 @@ module Clacky
926
1017
  # @param segment_end [Integer] End position of segment in line (char index)
927
1018
  # @return [String] Rendered segment with cursor if applicable
928
1019
  def render_line_segment_with_cursor(line, segment_start, segment_end)
929
- chars = line.chars
930
- segment_chars = chars[segment_start...segment_end]
931
-
932
- # Check if cursor is in this segment
933
- if @cursor_position >= segment_start && @cursor_position < segment_end
934
- # Cursor is in this segment
935
- cursor_pos_in_segment = @cursor_position - segment_start
936
- before_cursor = segment_chars[0...cursor_pos_in_segment].join
937
- cursor_char = segment_chars[cursor_pos_in_segment] || " "
938
- after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
939
-
940
- "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
941
- elsif @cursor_position == segment_end && segment_end == line.length
942
- # Cursor is at the very end of the line, show it in last segment
943
- segment_text = segment_chars.join
944
- "#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
945
- else
946
- # Cursor is not in this segment, just format normally
947
- theme.format_text(segment_chars.join, :user)
948
- end
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)
949
1024
  end
950
1025
 
1026
+ # Render a separator line (ensures it doesn't exceed screen width)
1027
+ # @param row [Integer] Row position to render
951
1028
  def render_separator(row)
952
1029
  move_cursor(row, 0)
953
- content = @pastel.dim("─" * @width)
954
- print_with_padding(content)
1030
+ # Ensure separator doesn't exceed screen width to prevent wrapping
1031
+ separator_width = [@width, 1].max
1032
+ content = @pastel.dim("─" * separator_width)
1033
+ print content
1034
+ # Clear any remaining space
1035
+ remaining = @width - separator_width
1036
+ print " " * remaining if remaining > 0
955
1037
  end
956
1038
 
1039
+ # Render session bar with wrapping support
1040
+ # @param row [Integer] Starting row position
1041
+ # @return [Integer] Number of rows actually used
957
1042
  def render_sessionbar(row)
958
1043
  move_cursor(row, 0)
959
1044
 
960
1045
  # If no sessionbar info, just render a separator
961
1046
  unless @sessionbar_info[:working_dir]
962
- content = @pastel.dim("─" * @width)
963
- print_with_padding(content)
964
- return
1047
+ separator_width = [@width, 1].max
1048
+ content = @pastel.dim("─" * separator_width)
1049
+ print content
1050
+ remaining = @width - separator_width
1051
+ print " " * remaining if remaining > 0
1052
+ return 1
965
1053
  end
966
1054
 
1055
+ session_line = build_sessionbar_content
1056
+
1057
+ # IMPORTANT: Always use print_with_padding which handles truncation
1058
+ # to prevent terminal auto-wrap
1059
+ print_with_padding(session_line)
1060
+ 1
1061
+ end
1062
+
1063
+ # Build the session bar content string
1064
+ # @return [String] Formatted session bar content
1065
+ private def build_sessionbar_content
967
1066
  parts = []
968
1067
  separator = @pastel.dim(" │ ")
969
1068
 
@@ -998,8 +1097,47 @@ module Clacky
998
1097
  cost_display = format("$%.1f", @sessionbar_info[:cost])
999
1098
  parts << @pastel.dim(@pastel.white(cost_display))
1000
1099
 
1001
- session_line = " " + parts.join(separator)
1002
- print_with_padding(session_line)
1100
+ " " + parts.join(separator)
1101
+ end
1102
+
1103
+ # Truncate session bar content to fit within max length
1104
+ # @param content [String] Full session bar content with ANSI codes
1105
+ # @param max_length [Integer] Maximum visible length
1106
+ # @return [String] Truncated content
1107
+ private def truncate_sessionbar_content(content, max_length)
1108
+ # Strip ANSI codes to calculate visible length
1109
+ visible_content = strip_ansi_codes(content)
1110
+ visible_width = calculate_display_width(visible_content)
1111
+
1112
+ return content if visible_width <= max_length
1113
+
1114
+ # Truncate from the end with "..." indicator
1115
+ chars = visible_content.chars
1116
+ current_width = 0
1117
+ truncate_at = 0
1118
+
1119
+ chars.each_with_index do |char, idx|
1120
+ char_width = char_display_width(char)
1121
+ if current_width + char_width + 3 > max_length # Reserve 3 for "..."
1122
+ truncate_at = idx
1123
+ break
1124
+ end
1125
+ current_width += char_width
1126
+ truncate_at = idx + 1
1127
+ end
1128
+
1129
+ # For simplicity with ANSI codes, just show first part + ...
1130
+ # This is a simplified version - proper implementation would preserve ANSI codes
1131
+ visible_content[0...truncate_at] + "..."
1132
+ end
1133
+
1134
+ # Calculate how many rows the session bar will occupy
1135
+ # @return [Integer] Number of rows needed
1136
+ private def calculate_sessionbar_height
1137
+ return 1 unless @sessionbar_info[:working_dir]
1138
+
1139
+ # Session bar always renders on one line (we truncate if needed)
1140
+ 1
1003
1141
  end
1004
1142
 
1005
1143
  def shorten_path(path)