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
|
@@ -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
|
|
@@ -32,7 +32,7 @@ module Clacky
|
|
|
32
32
|
user: [:white, :white],
|
|
33
33
|
assistant: [:bright_green, :white],
|
|
34
34
|
tool_call: [:bright_cyan, :cyan],
|
|
35
|
-
tool_result: [:
|
|
35
|
+
tool_result: [:bright_cyan, :cyan],
|
|
36
36
|
tool_denied: [:bright_yellow, :yellow],
|
|
37
37
|
tool_planned: [:bright_cyan, :cyan],
|
|
38
38
|
tool_error: [:bright_red, :red],
|
|
@@ -29,7 +29,7 @@ module Clacky
|
|
|
29
29
|
user: [:white, :white],
|
|
30
30
|
assistant: [:green, :white],
|
|
31
31
|
tool_call: [:cyan, :cyan],
|
|
32
|
-
tool_result: [:
|
|
32
|
+
tool_result: [:cyan, :cyan],
|
|
33
33
|
tool_denied: [:yellow, :yellow],
|
|
34
34
|
tool_planned: [:cyan, :cyan],
|
|
35
35
|
tool_error: [:red, :red],
|
|
@@ -147,6 +147,12 @@ module Clacky
|
|
|
147
147
|
@mode_toggle_callback = block
|
|
148
148
|
end
|
|
149
149
|
|
|
150
|
+
# Set skill loader for command suggestions
|
|
151
|
+
# @param skill_loader [Clacky::SkillLoader] The skill loader instance
|
|
152
|
+
def set_skill_loader(skill_loader)
|
|
153
|
+
@input_area.set_skill_loader(skill_loader)
|
|
154
|
+
end
|
|
155
|
+
|
|
150
156
|
# Append output to the output area
|
|
151
157
|
# @param content [String] Content to append
|
|
152
158
|
def append_output(content)
|
|
@@ -214,7 +220,7 @@ module Clacky
|
|
|
214
220
|
|
|
215
221
|
# Delta tokens with color coding (green/yellow/red + dim)
|
|
216
222
|
delta_tokens = token_data[:delta_tokens]
|
|
217
|
-
delta_str = "+#{delta_tokens}"
|
|
223
|
+
delta_str = delta_tokens.negative? ? "#{delta_tokens}" : "+#{delta_tokens}"
|
|
218
224
|
color_style = if delta_tokens > 10000
|
|
219
225
|
:red
|
|
220
226
|
elsif delta_tokens > 5000
|
|
@@ -222,7 +228,11 @@ module Clacky
|
|
|
222
228
|
else
|
|
223
229
|
:green
|
|
224
230
|
end
|
|
225
|
-
colored_delta =
|
|
231
|
+
colored_delta = if delta_tokens.negative?
|
|
232
|
+
pastel.cyan(delta_str)
|
|
233
|
+
else
|
|
234
|
+
pastel.decorate(delta_str, color_style, :dim)
|
|
235
|
+
end
|
|
226
236
|
token_info << colored_delta
|
|
227
237
|
|
|
228
238
|
# Cache status indicator (using theme)
|
|
@@ -342,10 +352,13 @@ module Clacky
|
|
|
342
352
|
|
|
343
353
|
# Remove <think>...</think> blocks (multiline, case-insensitive)
|
|
344
354
|
# Also handles variations like <thinking>...</thinking>
|
|
345
|
-
filtered = content.gsub(%r{<think(?:ing)
|
|
355
|
+
filtered = content.gsub(%r{<think(?:ing)?>[\s\S]*?</think(?:ing)?>}mi, '')
|
|
346
356
|
|
|
347
|
-
# Clean up
|
|
348
|
-
filtered.gsub(/\n{3,}/, "\n\n")
|
|
357
|
+
# Clean up multiple empty lines left behind (max 2 consecutive newlines)
|
|
358
|
+
filtered.gsub!(/\n{3,}/, "\n\n")
|
|
359
|
+
|
|
360
|
+
# Remove leading and trailing whitespace
|
|
361
|
+
filtered.strip
|
|
349
362
|
end
|
|
350
363
|
|
|
351
364
|
# Show tool call
|
|
@@ -380,7 +393,7 @@ module Clacky
|
|
|
380
393
|
def show_complete(iterations:, cost:, duration: nil, cache_stats: nil)
|
|
381
394
|
# Update status back to 'idle' when task is complete
|
|
382
395
|
update_sessionbar(status: 'idle')
|
|
383
|
-
|
|
396
|
+
|
|
384
397
|
# Clear user tip when agent stops working
|
|
385
398
|
@input_area.clear_user_tip
|
|
386
399
|
@layout.render_input
|
|
@@ -439,10 +452,10 @@ module Clacky
|
|
|
439
452
|
def clear_progress
|
|
440
453
|
# Calculate elapsed time before stopping
|
|
441
454
|
elapsed_time = @progress_start_time ? (Time.now - @progress_start_time).to_i : 0
|
|
442
|
-
|
|
455
|
+
|
|
443
456
|
# Stop the progress thread
|
|
444
457
|
stop_progress_thread
|
|
445
|
-
|
|
458
|
+
|
|
446
459
|
# Update the final progress line to gray (stopped state)
|
|
447
460
|
if @progress_message && elapsed_time > 0
|
|
448
461
|
final_output = @renderer.render_progress("#{@progress_message}… (#{elapsed_time}s)")
|
|
@@ -544,7 +557,7 @@ module Clacky
|
|
|
544
557
|
|
|
545
558
|
# Create InlineInput with styled prompt
|
|
546
559
|
inline_input = Components::InlineInput.new(
|
|
547
|
-
prompt: "
|
|
560
|
+
prompt: "Press Enter/y to approve(Shift+Tab for all), 'n' to reject, or type feedback: ",
|
|
548
561
|
default: nil
|
|
549
562
|
)
|
|
550
563
|
@inline_input = inline_input
|
|
@@ -556,8 +569,9 @@ module Clacky
|
|
|
556
569
|
# Collect input (blocks until user presses Enter)
|
|
557
570
|
result_text = inline_input.collect
|
|
558
571
|
|
|
559
|
-
# Clean up - remove the inline input
|
|
560
|
-
|
|
572
|
+
# Clean up - remove the inline input lines (handle wrapped lines)
|
|
573
|
+
line_count = inline_input.line_count
|
|
574
|
+
@layout.remove_last_line(line_count)
|
|
561
575
|
|
|
562
576
|
# Append the final response to output
|
|
563
577
|
if result_text.nil?
|
|
@@ -594,41 +608,15 @@ module Clacky
|
|
|
594
608
|
require 'diffy'
|
|
595
609
|
|
|
596
610
|
diff = Diffy::Diff.new(old_content, new_content, context: 3)
|
|
597
|
-
|
|
598
|
-
plain_lines = diff.to_s.lines
|
|
599
|
-
|
|
600
|
-
# Add line numbers to diff output
|
|
601
|
-
old_line_num = 0
|
|
602
|
-
new_line_num = 0
|
|
603
|
-
|
|
604
|
-
numbered_lines = all_lines.each_with_index.map do |line, index|
|
|
605
|
-
# Use plain text to detect line type (remove ANSI codes)
|
|
606
|
-
plain_line = plain_lines[index]&.chomp || line.gsub(/\e\[[0-9;]*m/, '').chomp
|
|
607
|
-
|
|
608
|
-
# Remove trailing newline from colored line to avoid double newlines
|
|
609
|
-
colored_line = line.chomp
|
|
610
|
-
|
|
611
|
-
# Determine line type and number (use single line number for simplicity)
|
|
612
|
-
if plain_line.start_with?('+') || plain_line.start_with?('-') || plain_line.start_with?(' ')
|
|
613
|
-
new_line_num += 1
|
|
614
|
-
sprintf("%4d | %s", new_line_num, colored_line)
|
|
615
|
-
elsif plain_line.start_with?('@@')
|
|
616
|
-
# Diff header: extract line numbers from @@ -old_start,old_count +new_start,new_count @@
|
|
617
|
-
if plain_line =~ /@@ -(\d+)(?:,\d+)? (\d+)(?:,\d+)? @@/
|
|
618
|
-
new_line_num = $2.to_i - 1
|
|
619
|
-
end
|
|
620
|
-
sprintf("%4s | %s", "", colored_line)
|
|
621
|
-
else
|
|
622
|
-
# Other lines (headers, etc.)
|
|
623
|
-
sprintf("%4s | %s", "", colored_line)
|
|
624
|
-
end
|
|
625
|
-
end
|
|
611
|
+
diff_lines = diff.to_s(:color).lines
|
|
626
612
|
|
|
627
|
-
|
|
628
|
-
|
|
613
|
+
# Show diff without line numbers
|
|
614
|
+
diff_lines.take(max_lines).each do |line|
|
|
615
|
+
append_output(line.chomp)
|
|
616
|
+
end
|
|
629
617
|
|
|
630
|
-
if
|
|
631
|
-
append_output("\n... (#{
|
|
618
|
+
if diff_lines.size > max_lines
|
|
619
|
+
append_output("\n... (#{diff_lines.size - max_lines} more lines, diff truncated)")
|
|
632
620
|
end
|
|
633
621
|
rescue LoadError
|
|
634
622
|
# Fallback if diffy is not available
|
|
@@ -788,16 +776,19 @@ module Clacky
|
|
|
788
776
|
|
|
789
777
|
# Handle key input for InlineInput
|
|
790
778
|
def handle_inline_input_key(key)
|
|
779
|
+
# Get old line count BEFORE modification
|
|
780
|
+
old_line_count = @inline_input.line_count
|
|
781
|
+
|
|
791
782
|
result = @inline_input.handle_key(key)
|
|
792
783
|
|
|
793
784
|
case result[:action]
|
|
794
785
|
when :update
|
|
795
|
-
# Update the
|
|
796
|
-
@layout.update_last_line(@inline_input.render)
|
|
786
|
+
# Update the output area with current input (considering wrapped lines)
|
|
787
|
+
@layout.update_last_line(@inline_input.render, old_line_count)
|
|
797
788
|
# Position cursor for inline input
|
|
798
789
|
@layout.position_inline_input_cursor(@inline_input)
|
|
799
790
|
when :submit, :cancel
|
|
800
|
-
# InlineInput is done, will be cleaned up by request_confirmation
|
|
791
|
+
# InlineInput is done, will be cleaned up by request_confirmation after collect returns
|
|
801
792
|
nil
|
|
802
793
|
when :toggle_mode
|
|
803
794
|
# Update mode and session bar info, but don't render yet
|
|
@@ -30,20 +30,18 @@ module Clacky
|
|
|
30
30
|
/\.ini$/,
|
|
31
31
|
/\.conf$/,
|
|
32
32
|
/\.config$/,
|
|
33
|
-
/config\//,
|
|
34
|
-
/\.config\//
|
|
35
33
|
].freeze
|
|
36
34
|
|
|
37
35
|
# Find .gitignore file in the search path or parent directories
|
|
38
36
|
# Only searches within the search path and up to the current working directory
|
|
39
37
|
def self.find_gitignore(path)
|
|
40
38
|
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
41
|
-
|
|
39
|
+
|
|
42
40
|
# Look for .gitignore in current and parent directories
|
|
43
41
|
current = File.expand_path(search_path)
|
|
44
42
|
cwd = File.expand_path(Dir.pwd)
|
|
45
43
|
root = File.expand_path('/')
|
|
46
|
-
|
|
44
|
+
|
|
47
45
|
# Limit search: only go up to current working directory
|
|
48
46
|
# This prevents finding .gitignore files from unrelated parent directories
|
|
49
47
|
# when searching in temporary directories (like /tmp in tests)
|
|
@@ -52,16 +50,16 @@ module Clacky
|
|
|
52
50
|
else
|
|
53
51
|
current
|
|
54
52
|
end
|
|
55
|
-
|
|
53
|
+
|
|
56
54
|
loop do
|
|
57
55
|
gitignore = File.join(current, '.gitignore')
|
|
58
56
|
return gitignore if File.exist?(gitignore)
|
|
59
|
-
|
|
57
|
+
|
|
60
58
|
# Stop if we've reached the search limit or root
|
|
61
59
|
break if current == search_limit || current == root
|
|
62
60
|
current = File.dirname(current)
|
|
63
61
|
end
|
|
64
|
-
|
|
62
|
+
|
|
65
63
|
nil
|
|
66
64
|
end
|
|
67
65
|
|
|
@@ -71,10 +69,10 @@ module Clacky
|
|
|
71
69
|
# Expand both paths to handle symlinks and relative paths correctly
|
|
72
70
|
expanded_file = File.expand_path(file)
|
|
73
71
|
expanded_base = File.expand_path(base_path)
|
|
74
|
-
|
|
72
|
+
|
|
75
73
|
# For files, use the directory as base
|
|
76
74
|
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
77
|
-
|
|
75
|
+
|
|
78
76
|
# Calculate relative path
|
|
79
77
|
if expanded_file.start_with?(expanded_base)
|
|
80
78
|
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
@@ -82,10 +80,10 @@ module Clacky
|
|
|
82
80
|
# File is outside base path - use just the filename
|
|
83
81
|
relative_path = File.basename(expanded_file)
|
|
84
82
|
end
|
|
85
|
-
|
|
83
|
+
|
|
86
84
|
# Clean up relative path
|
|
87
85
|
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
88
|
-
|
|
86
|
+
|
|
89
87
|
if gitignore
|
|
90
88
|
# Use .gitignore rules
|
|
91
89
|
gitignore.ignored?(relative_path)
|
|
@@ -96,7 +94,7 @@ module Clacky
|
|
|
96
94
|
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
97
95
|
else
|
|
98
96
|
# Match pattern as a path component (not substring of absolute path)
|
|
99
|
-
relative_path.start_with?("#{pattern}/") ||
|
|
97
|
+
relative_path.start_with?("#{pattern}/") ||
|
|
100
98
|
relative_path.include?("/#{pattern}/") ||
|
|
101
99
|
relative_path == pattern ||
|
|
102
100
|
File.basename(relative_path) == pattern
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "clacky/version"
|
|
4
4
|
require_relative "clacky/config"
|
|
5
5
|
require_relative "clacky/client"
|
|
6
|
+
require_relative "clacky/skill"
|
|
7
|
+
require_relative "clacky/skill_loader"
|
|
6
8
|
|
|
7
9
|
# Agent system
|
|
8
10
|
require_relative "clacky/model_pricing"
|
|
@@ -35,6 +37,7 @@ require_relative "clacky/agent"
|
|
|
35
37
|
require_relative "clacky/cli"
|
|
36
38
|
|
|
37
39
|
module Clacky
|
|
38
|
-
class
|
|
40
|
+
class AgentError < StandardError; end
|
|
39
41
|
class AgentInterrupted < StandardError; end
|
|
42
|
+
class ToolCallError < AgentError; end # Raised when tool call fails due to invalid parameters
|
|
40
43
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: openclacky
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -159,6 +159,7 @@ files:
|
|
|
159
159
|
- clacky-legacy/clacky.gemspec
|
|
160
160
|
- clacky-legacy/clarky.gemspec
|
|
161
161
|
- docs/ui2-architecture.md
|
|
162
|
+
- docs/why-openclacky.md
|
|
162
163
|
- homebrew/README.md
|
|
163
164
|
- homebrew/openclacky.rb
|
|
164
165
|
- lib/clacky.rb
|
|
@@ -167,11 +168,14 @@ files:
|
|
|
167
168
|
- lib/clacky/cli.rb
|
|
168
169
|
- lib/clacky/client.rb
|
|
169
170
|
- lib/clacky/config.rb
|
|
171
|
+
- lib/clacky/default_skills/skill-add/SKILL.md
|
|
170
172
|
- lib/clacky/gitignore_parser.rb
|
|
171
173
|
- lib/clacky/hook_manager.rb
|
|
172
174
|
- lib/clacky/model_pricing.rb
|
|
173
175
|
- lib/clacky/progress_indicator.rb
|
|
174
176
|
- lib/clacky/session_manager.rb
|
|
177
|
+
- lib/clacky/skill.rb
|
|
178
|
+
- lib/clacky/skill_loader.rb
|
|
175
179
|
- lib/clacky/thinking_verbs.rb
|
|
176
180
|
- lib/clacky/tool_registry.rb
|
|
177
181
|
- lib/clacky/tools/base.rb
|
|
@@ -191,6 +195,7 @@ files:
|
|
|
191
195
|
- lib/clacky/ui2.rb
|
|
192
196
|
- lib/clacky/ui2/README.md
|
|
193
197
|
- lib/clacky/ui2/components/base_component.rb
|
|
198
|
+
- lib/clacky/ui2/components/command_suggestions.rb
|
|
194
199
|
- lib/clacky/ui2/components/common_component.rb
|
|
195
200
|
- lib/clacky/ui2/components/inline_input.rb
|
|
196
201
|
- lib/clacky/ui2/components/input_area.rb
|