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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/docs/why-openclacky.md +267 -0
- data/lib/clacky/agent.rb +579 -99
- data/lib/clacky/cli.rb +350 -9
- data/lib/clacky/client.rb +519 -58
- data/lib/clacky/config.rb +71 -4
- 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/edit.rb +111 -8
- data/lib/clacky/tools/file_reader.rb +112 -9
- data/lib/clacky/tools/glob.rb +9 -2
- 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 +38 -47
- data/lib/clacky/utils/file_ignore_helper.rb +10 -12
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +4 -1
- metadata +6 -1
|
@@ -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
|
-
#
|
|
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)
|