openclacky 0.6.2 → 0.6.4

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.
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require_relative "../theme_manager"
5
+
6
+ module Clacky
7
+ module UI2
8
+ module Components
9
+ # CommandSuggestions displays a dropdown menu of available commands
10
+ # Supports keyboard navigation and filtering
11
+ class CommandSuggestions
12
+ attr_reader :selected_index, :visible
13
+
14
+ # System commands available by default
15
+ SYSTEM_COMMANDS = [
16
+ { command: "/clear", description: "Clear chat history and restart session" },
17
+ { command: "/help", description: "Show help information" },
18
+ { command: "/exit", description: "Exit the chat session" },
19
+ { command: "/quit", description: "Quit the application" }
20
+ ].freeze
21
+
22
+ def initialize
23
+ @pastel = Pastel.new
24
+ @commands = []
25
+ @filtered_commands = []
26
+ @selected_index = 0
27
+ @visible = false
28
+ @filter_text = ""
29
+ @skill_commands = []
30
+
31
+ # Initialize with system commands
32
+ update_commands
33
+ end
34
+
35
+ # Get current theme from ThemeManager
36
+ def theme
37
+ UI2::ThemeManager.current_theme
38
+ end
39
+
40
+ # Load skill commands from skill loader
41
+ # @param skill_loader [Clacky::SkillLoader] The skill loader instance
42
+ def load_skill_commands(skill_loader)
43
+ return unless skill_loader
44
+
45
+ @skill_commands = skill_loader.user_invocable_skills.map do |skill|
46
+ {
47
+ command: skill.slash_command,
48
+ description: skill.description || "No description available",
49
+ type: :skill
50
+ }
51
+ end
52
+
53
+ update_commands
54
+ end
55
+
56
+ # Show the suggestions dropdown
57
+ # @param filter_text [String] Initial filter text (everything after the /)
58
+ def show(filter_text = "")
59
+ @filter_text = filter_text
60
+ @visible = true
61
+ update_filtered_commands
62
+ @selected_index = 0
63
+ end
64
+
65
+ # Hide the suggestions dropdown
66
+ def hide
67
+ @visible = false
68
+ @filter_text = ""
69
+ @filtered_commands = []
70
+ @selected_index = 0
71
+ end
72
+
73
+ # Update filter text and refresh filtered commands
74
+ # @param text [String] Filter text (everything after the /)
75
+ def update_filter(text)
76
+ @filter_text = text
77
+ update_filtered_commands
78
+ @selected_index = 0 # Reset selection when filter changes
79
+ end
80
+
81
+ # Move selection up
82
+ def select_previous
83
+ return if @filtered_commands.empty?
84
+ @selected_index = (@selected_index - 1) % @filtered_commands.size
85
+ end
86
+
87
+ # Move selection down
88
+ def select_next
89
+ return if @filtered_commands.empty?
90
+ @selected_index = (@selected_index + 1) % @filtered_commands.size
91
+ end
92
+
93
+ # Get the currently selected command
94
+ # @return [Hash, nil] Selected command hash or nil if none selected
95
+ def selected_command
96
+ return nil if @filtered_commands.empty?
97
+ @filtered_commands[@selected_index]
98
+ end
99
+
100
+ # Get the currently selected command text
101
+ # @return [String, nil] Selected command text or nil if none selected
102
+ def selected_command_text
103
+ cmd = selected_command
104
+ cmd ? cmd[:command] : nil
105
+ end
106
+
107
+ # Check if there are any suggestions to show
108
+ # @return [Boolean]
109
+ def has_suggestions?
110
+ @visible && !@filtered_commands.empty?
111
+ end
112
+
113
+ # Calculate required height for rendering
114
+ # @return [Integer] Number of lines needed
115
+ def required_height
116
+ return 0 unless @visible
117
+ return 0 if @filtered_commands.empty?
118
+
119
+ # Header + commands + footer
120
+ 1 + [@filtered_commands.size, 5].min + 1 # Max 5 visible items
121
+ end
122
+
123
+ # Render the suggestions dropdown
124
+ # @param row [Integer] Starting row position
125
+ # @param col [Integer] Starting column position
126
+ # @param width [Integer] Maximum width for the dropdown
127
+ # @return [String] Rendered output
128
+ def render(row:, col:, width: 60)
129
+ return "" unless @visible
130
+ return "" if @filtered_commands.empty?
131
+
132
+ output = []
133
+ max_items = 5 # Maximum visible items
134
+ visible_commands = @filtered_commands.take(max_items)
135
+
136
+ # Header
137
+ header = @pastel.dim("┌─ Commands ") + @pastel.dim("─" * (width - 13)) + @pastel.dim("┐")
138
+ output << position_cursor(row, col) + header
139
+
140
+ # Items
141
+ visible_commands.each_with_index do |cmd, idx|
142
+ is_selected = (idx == @selected_index)
143
+ line = render_command_item(cmd, is_selected, width)
144
+ output << position_cursor(row + 1 + idx, col) + line
145
+ end
146
+
147
+ # Footer with navigation hint
148
+ footer_row = row + 1 + visible_commands.size
149
+ total = @filtered_commands.size
150
+ hint = total > max_items ? " (#{total - max_items} more...)" : ""
151
+ footer = @pastel.dim("└") + @pastel.dim("─" * (width - 2)) + @pastel.dim("┘")
152
+ output << position_cursor(footer_row, col) + footer
153
+
154
+ output.join
155
+ end
156
+
157
+ # Clear the rendered dropdown from screen
158
+ # @param row [Integer] Starting row position
159
+ # @param col [Integer] Starting column position
160
+ def clear_from_screen(row:, col:)
161
+ return unless @visible
162
+
163
+ height = required_height
164
+ output = []
165
+
166
+ height.times do |i|
167
+ output << position_cursor(row + i, col) + clear_line
168
+ end
169
+
170
+ print output.join
171
+ flush
172
+ end
173
+
174
+ private
175
+
176
+ # Update the complete commands list (system + skills)
177
+ private def update_commands
178
+ system_cmds = SYSTEM_COMMANDS.map { |c| c.merge(type: :system) }
179
+ @commands = system_cmds + @skill_commands
180
+ update_filtered_commands if @visible
181
+ end
182
+
183
+ # Update filtered commands based on current filter text
184
+ private def update_filtered_commands
185
+ if @filter_text.empty?
186
+ @filtered_commands = @commands
187
+ else
188
+ filter_lower = @filter_text.downcase
189
+ @filtered_commands = @commands.select do |cmd|
190
+ # Remove leading / for comparison
191
+ cmd_name = cmd[:command].sub(/^\//, "")
192
+ cmd_name.downcase.start_with?(filter_lower) ||
193
+ cmd[:description].downcase.include?(filter_lower)
194
+ end
195
+ end
196
+ end
197
+
198
+ # Render a single command item
199
+ # @param cmd [Hash] Command hash with :command and :description
200
+ # @param selected [Boolean] Whether this item is selected
201
+ # @param width [Integer] Maximum width
202
+ # @return [String] Rendered item
203
+ private def render_command_item(cmd, selected, width)
204
+ # Calculate available space
205
+ available = width - 4 # Account for borders and padding
206
+
207
+ # Format command (e.g., "/clear")
208
+ command_text = cmd[:command]
209
+
210
+ # Format description
211
+ max_desc_length = available - command_text.length - 3 # 3 for spacing
212
+ description = truncate_text(cmd[:description], max_desc_length)
213
+
214
+ # Build line
215
+ if selected
216
+ # Highlighted selection
217
+ line = @pastel.on_blue(@pastel.white(" #{command_text} "))
218
+ line += @pastel.on_blue(@pastel.dim(" #{description}"))
219
+ # Pad to full width
220
+ content_length = command_text.length + description.length + 2
221
+ padding = " " * [available - content_length, 0].max
222
+ line += @pastel.on_blue(padding)
223
+ @pastel.dim("│") + line + @pastel.dim("│")
224
+ else
225
+ # Normal item
226
+ line = " #{@pastel.cyan(command_text)} #{@pastel.dim(description)}"
227
+ # Pad to full width
228
+ content_length = strip_ansi(line).length
229
+ padding = " " * [available - content_length, 0].max
230
+ @pastel.dim("│") + line + padding + @pastel.dim("│")
231
+ end
232
+ end
233
+
234
+ # Truncate text to maximum length
235
+ # @param text [String] Text to truncate
236
+ # @param max_length [Integer] Maximum length
237
+ # @return [String] Truncated text
238
+ private def truncate_text(text, max_length)
239
+ return "" if max_length <= 3
240
+ return text if text.length <= max_length
241
+
242
+ text[0...(max_length - 3)] + "..."
243
+ end
244
+
245
+ # Strip ANSI codes from text
246
+ # @param text [String] Text with ANSI codes
247
+ # @return [String] Plain text
248
+ private def strip_ansi(text)
249
+ text.gsub(/\e\[[0-9;]*m/, '')
250
+ end
251
+
252
+ # Position cursor at specific row and column
253
+ # @param row [Integer] Row position (0-indexed)
254
+ # @param col [Integer] Column position (0-indexed)
255
+ # @return [String] ANSI escape sequence
256
+ private def position_cursor(row, col)
257
+ "\e[#{row + 1};#{col + 1}H"
258
+ end
259
+
260
+ # Clear current line
261
+ # @return [String] ANSI escape sequence
262
+ private def clear_line
263
+ "\e[2K"
264
+ end
265
+
266
+ # Flush output to terminal
267
+ private def flush
268
+ $stdout.flush
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end
@@ -109,24 +109,31 @@ module Clacky
109
109
  # Render inline input with prompt and cursor
110
110
  # @return [String] Rendered line (may wrap to multiple lines)
111
111
  def render
112
- line = render_line_with_cursor
113
- full_text = "#{@prompt}#{line}"
114
-
115
- # Calculate terminal width and check if wrapping is needed
116
112
  width = TTY::Screen.width
117
- visible_text = strip_ansi_codes(full_text)
118
- display_width = calculate_display_width(visible_text)
119
-
120
- # If no wrapping needed, return as is
121
- return full_text if display_width <= width
122
-
123
- # Otherwise, wrap the input (prompt on first line, continuation indented)
124
113
  prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
125
114
  available_width = width - prompt_width
126
-
127
- # For simplicity, just return full text and let terminal handle wrapping
128
- # InlineInput is typically short, so natural wrapping should be fine
129
- full_text
115
+
116
+ # Get wrapped segments
117
+ wrapped_segments = wrap_line(@line, available_width)
118
+
119
+ # Build rendered output with cursor
120
+ output = ""
121
+
122
+ wrapped_segments.each_with_index do |segment, idx|
123
+ prefix = if idx == 0
124
+ @prompt
125
+ else
126
+ "> " # Continuation prompt indicator
127
+ end
128
+
129
+ # Render segment with cursor if needed
130
+ segment_text = render_line_segment_with_cursor(@line, segment[:start], segment[:end])
131
+
132
+ output += "#{prefix}#{segment_text}"
133
+ output += "\n" unless idx == wrapped_segments.size - 1
134
+ end
135
+
136
+ output
130
137
  end
131
138
 
132
139
  # Get cursor column position
@@ -135,6 +142,18 @@ module Clacky
135
142
  cursor_column(@prompt)
136
143
  end
137
144
 
145
+ # Get the number of lines this input will occupy when rendered
146
+ # @param width [Integer] Terminal width
147
+ # @return [Integer] Number of lines
148
+ def line_count(width = TTY::Screen.width)
149
+ prompt_width = calculate_display_width(strip_ansi_codes(@prompt))
150
+ available_width = width - prompt_width
151
+ return 1 if available_width <= 0
152
+
153
+ segments = wrap_line(@line, available_width)
154
+ segments.size
155
+ end
156
+
138
157
  # Deactivate inline input
139
158
  def deactivate
140
159
  @active = false
@@ -4,6 +4,7 @@ require "pastel"
4
4
  require "tempfile"
5
5
  require_relative "../theme_manager"
6
6
  require_relative "../line_editor"
7
+ require_relative "command_suggestions"
7
8
 
8
9
  module Clacky
9
10
  module UI2
@@ -68,6 +69,10 @@ module Clacky
68
69
  @animation_frame = 0
69
70
  @last_animation_update = Time.now
70
71
  @working_frames = ["❄", "❅", "❆"]
72
+
73
+ # Command suggestions dropdown
74
+ @command_suggestions = CommandSuggestions.new
75
+ @skill_loader = nil # Will be set via set_skill_loader method
71
76
  end
72
77
 
73
78
  # Get current theme from ThemeManager
@@ -111,6 +116,9 @@ module Clacky
111
116
  # Bottom separator
112
117
  height += 1
113
118
 
119
+ # Command suggestions (rendered above input)
120
+ height += @command_suggestions.required_height if @command_suggestions
121
+
114
122
  # Tips and user tips
115
123
  height += 1 if @tips_message
116
124
  height += 1 if @user_tip
@@ -118,6 +126,13 @@ module Clacky
118
126
  height
119
127
  end
120
128
 
129
+ # Set skill loader for command suggestions
130
+ # @param skill_loader [Clacky::SkillLoader] The skill loader instance
131
+ def set_skill_loader(skill_loader)
132
+ @skill_loader = skill_loader
133
+ @command_suggestions.load_skill_commands(skill_loader) if skill_loader
134
+ end
135
+
121
136
  # Update session bar info
122
137
  # @param working_dir [String] Working directory
123
138
  # @param mode [String] Permission mode
@@ -144,17 +159,65 @@ module Clacky
144
159
 
145
160
  old_height = required_height
146
161
 
162
+ # Handle command suggestions navigation first if visible
163
+ if @command_suggestions.visible
164
+ case key
165
+ when :up_arrow
166
+ @command_suggestions.select_previous
167
+ return { action: nil }
168
+ when :down_arrow
169
+ @command_suggestions.select_next
170
+ return { action: nil }
171
+ when :enter
172
+ # Accept selected command
173
+ if @command_suggestions.has_suggestions?
174
+ selected = @command_suggestions.selected_command_text
175
+ if selected
176
+ # Replace current input with selected command
177
+ @lines = [selected]
178
+ @line_index = 0
179
+ @cursor_position = selected.length
180
+ @command_suggestions.hide
181
+ return { action: nil }
182
+ end
183
+ end
184
+ # Fall through to normal enter handling if no suggestion
185
+ when :escape
186
+ @command_suggestions.hide
187
+ return { action: nil }
188
+ when :tab
189
+ # Tab also accepts the suggestion
190
+ if @command_suggestions.has_suggestions?
191
+ selected = @command_suggestions.selected_command_text
192
+ if selected
193
+ @lines = [selected]
194
+ @line_index = 0
195
+ @cursor_position = selected.length
196
+ @command_suggestions.hide
197
+ return { action: nil }
198
+ end
199
+ end
200
+ end
201
+ end
202
+
147
203
  result = case key
148
204
  when Hash
149
205
  if key[:type] == :rapid_input
150
206
  insert_text(key[:text])
151
207
  clear_tips
208
+ update_command_suggestions
152
209
  end
153
210
  { action: nil }
154
211
  when :enter then handle_enter
155
212
  when :newline then newline; { action: nil }
156
- when :backspace then backspace; { action: nil }
157
- when :delete then delete_char; { action: nil }
213
+ when :backspace
214
+ backspace
215
+ update_command_suggestions
216
+ { action: nil }
217
+ when :delete
218
+ delete_char
219
+ update_command_suggestions
220
+ { action: nil }
158
221
  when :left_arrow, :ctrl_b then cursor_left; { action: nil }
159
222
  when :right_arrow, :ctrl_f then cursor_right; { action: nil }
160
223
  when :up_arrow then handle_up_arrow
@@ -168,10 +231,15 @@ module Clacky
168
231
  when :ctrl_d then handle_ctrl_d
169
232
  when :ctrl_v then handle_paste
170
233
  when :shift_tab then { action: :toggle_mode }
171
- when :escape then { action: nil }
234
+ when :escape
235
+ if @command_suggestions.visible
236
+ @command_suggestions.hide
237
+ end
238
+ { action: nil }
172
239
  else
173
240
  if key.is_a?(String) && key.length >= 1 && key.ord >= 32
174
241
  insert_char(key)
242
+ update_command_suggestions
175
243
  end
176
244
  { action: nil }
177
245
  end
@@ -219,6 +287,13 @@ module Clacky
219
287
  render_separator(current_row)
220
288
  current_row += 1
221
289
 
290
+ # Command suggestions (rendered above tips)
291
+ if @command_suggestions && @command_suggestions.visible
292
+ # Render suggestions at current row
293
+ print @command_suggestions.render(row: current_row, col: 0, width: [@width - 4, 60].min)
294
+ current_row += @command_suggestions.required_height
295
+ end
296
+
222
297
  # Tips bar (if any)
223
298
  if @tips_message
224
299
  move_cursor(current_row, 0)
@@ -495,6 +570,7 @@ module Clacky
495
570
  @paste_counter = 0
496
571
  @paste_placeholders = {}
497
572
  clear_tips
573
+ @command_suggestions.hide if @command_suggestions
498
574
  end
499
575
 
500
576
  def submit
@@ -530,6 +606,23 @@ module Clacky
530
606
 
531
607
  private
532
608
 
609
+ # Update command suggestions based on current input
610
+ # Shows suggestions when input starts with /
611
+ private def update_command_suggestions
612
+ return unless @command_suggestions
613
+
614
+ current = current_line.strip
615
+
616
+ # Check if we should show suggestions (input starts with /)
617
+ if current.start_with?('/') && @line_index == 0
618
+ # Extract the filter text (everything after /)
619
+ filter_text = current[1..-1] || ""
620
+ @command_suggestions.show(filter_text)
621
+ else
622
+ @command_suggestions.hide
623
+ end
624
+ end
625
+
533
626
  # Render all input lines with auto-wrap support
534
627
  # @param start_row [Integer] Starting row position
535
628
  # @return [Integer] Next available row after rendering all lines
@@ -603,69 +696,14 @@ module Clacky
603
696
  # @param max_width [Integer] Maximum display width per wrapped line
604
697
  # @return [Array<Hash>] Array of segment info: { text: String, start: Integer, end: Integer }
605
698
  def wrap_line(line, max_width)
606
- return [{ text: "", start: 0, end: 0 }] if line.empty?
607
- return [{ text: line, start: 0, end: line.length }] if max_width <= 0
608
-
609
- segments = []
610
- chars = line.chars
611
- segment_start = 0
612
- current_width = 0
613
- current_end = 0
614
-
615
- chars.each_with_index do |char, idx|
616
- char_width = char_display_width(char)
617
-
618
- # If adding this character exceeds max width, complete current segment
619
- if current_width + char_width > max_width && current_end > segment_start
620
- segments << {
621
- text: chars[segment_start...current_end].join,
622
- start: segment_start,
623
- end: current_end
624
- }
625
- segment_start = idx
626
- current_end = idx + 1
627
- current_width = char_width
628
- else
629
- current_end = idx + 1
630
- current_width += char_width
631
- end
632
- end
633
-
634
- # Add the last segment
635
- if current_end > segment_start
636
- segments << {
637
- text: chars[segment_start...current_end].join,
638
- start: segment_start,
639
- end: current_end
640
- }
641
- end
642
-
643
- segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
699
+ super(line, max_width)
644
700
  end
645
701
 
646
702
  # Calculate display width of a single character
647
703
  # @param char [String] Single character
648
704
  # @return [Integer] Display width (1 or 2)
649
705
  def char_display_width(char)
650
- code = char.ord
651
- # East Asian Wide and Fullwidth characters take 2 columns
652
- if (code >= 0x1100 && code <= 0x115F) ||
653
- (code >= 0x2329 && code <= 0x232A) ||
654
- (code >= 0x2E80 && code <= 0x303E) ||
655
- (code >= 0x3040 && code <= 0xA4CF) ||
656
- (code >= 0xAC00 && code <= 0xD7A3) ||
657
- (code >= 0xF900 && code <= 0xFAFF) ||
658
- (code >= 0xFE10 && code <= 0xFE19) ||
659
- (code >= 0xFE30 && code <= 0xFE6F) ||
660
- (code >= 0xFF00 && code <= 0xFF60) ||
661
- (code >= 0xFFE0 && code <= 0xFFE6) ||
662
- (code >= 0x1F300 && code <= 0x1F9FF) ||
663
- (code >= 0x20000 && code <= 0x2FFFD) ||
664
- (code >= 0x30000 && code <= 0x3FFFD)
665
- 2
666
- else
667
- 1
668
- end
706
+ super(char)
669
707
  end
670
708
 
671
709
  # Strip ANSI escape codes from a string
@@ -714,7 +752,7 @@ module Clacky
714
752
  if text.start_with?('/')
715
753
  # Check if it's a command (single slash followed by English letters only)
716
754
  # Paths like /xxx/xxxx should not be treated as commands
717
- if text =~ /^\/([a-zA-Z]+)$/
755
+ if text =~ /^\/([a-zA-Z-]+)$/
718
756
  case text
719
757
  when '/clear'
720
758
  clear
@@ -724,8 +762,8 @@ module Clacky
724
762
  when '/exit', '/quit'
725
763
  return { action: :exit }
726
764
  else
727
- set_tips("Unknown command: #{text} (Available: /clear, /help, /exit)", type: :warning)
728
- return { action: nil }
765
+ # Let other commands (like skills) pass through to agent
766
+ # Fall through to submit
729
767
  end
730
768
  end
731
769
  # If it's not a command pattern (e.g., /xxx/xxxx), treat as normal input
@@ -979,26 +1017,10 @@ module Clacky
979
1017
  # @param segment_end [Integer] End position of segment in line (char index)
980
1018
  # @return [String] Rendered segment with cursor if applicable
981
1019
  def render_line_segment_with_cursor(line, segment_start, segment_end)
982
- chars = line.chars
983
- segment_chars = chars[segment_start...segment_end]
984
-
985
- # Check if cursor is in this segment
986
- if @cursor_position >= segment_start && @cursor_position < segment_end
987
- # Cursor is in this segment
988
- cursor_pos_in_segment = @cursor_position - segment_start
989
- before_cursor = segment_chars[0...cursor_pos_in_segment].join
990
- cursor_char = segment_chars[cursor_pos_in_segment] || " "
991
- after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
992
-
993
- "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
994
- elsif @cursor_position == segment_end && segment_end == line.length
995
- # Cursor is at the very end of the line, show it in last segment
996
- segment_text = segment_chars.join
997
- "#{@pastel.white(segment_text)}#{@pastel.on_white(@pastel.black(' '))}"
998
- else
999
- # Cursor is not in this segment, just format normally
1000
- theme.format_text(segment_chars.join, :user)
1001
- end
1020
+ # Delegate to LineEditor's shared implementation
1021
+ rendered = super(line, segment_start, segment_end)
1022
+ # Apply theme colors for InputArea
1023
+ theme.format_text(rendered, :user)
1002
1024
  end
1003
1025
 
1004
1026
  # Render a separator line (ensures it doesn't exceed screen width)