openclacky 0.5.3 → 0.5.5
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 +13 -0
- data/README.md +2 -0
- data/Rakefile +1 -5
- data/lib/clacky/agent.rb +34 -16
- data/lib/clacky/cli.rb +54 -133
- data/lib/clacky/client.rb +53 -7
- data/lib/clacky/gitignore_parser.rb +114 -0
- data/lib/clacky/model_pricing.rb +280 -0
- data/lib/clacky/tools/grep.rb +268 -30
- data/lib/clacky/tools/safe_shell.rb +9 -4
- data/lib/clacky/tools/shell.rb +60 -22
- data/lib/clacky/ui/banner.rb +22 -11
- data/lib/clacky/ui/enhanced_prompt.rb +540 -0
- data/lib/clacky/ui/statusbar.rb +0 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +3 -2
- metadata +4 -3
- data/lib/clacky/conversation.rb +0 -41
- data/lib/clacky/ui/prompt.rb +0 -72
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "pastel"
|
|
5
|
+
require "tty-screen"
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "base64"
|
|
8
|
+
|
|
9
|
+
module Clacky
|
|
10
|
+
module UI
|
|
11
|
+
# Enhanced input prompt with multi-line support and image paste
|
|
12
|
+
#
|
|
13
|
+
# Features:
|
|
14
|
+
# - Shift+Enter: Add new line
|
|
15
|
+
# - Enter: Submit message
|
|
16
|
+
# - Ctrl+V: Paste text or images from clipboard
|
|
17
|
+
# - Image preview and management
|
|
18
|
+
class EnhancedPrompt
|
|
19
|
+
attr_reader :images
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@pastel = Pastel.new
|
|
23
|
+
@images = [] # Array of image file paths
|
|
24
|
+
@paste_counter = 0 # Counter for paste operations
|
|
25
|
+
@paste_placeholders = {} # Map of placeholder text to actual pasted content
|
|
26
|
+
@last_input_time = nil # Track last input time for rapid input detection
|
|
27
|
+
@rapid_input_threshold = 0.01 # 10ms threshold for detecting paste-like rapid input
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Read user input with enhanced features
|
|
31
|
+
# @param prefix [String] Prompt prefix (default: "❯")
|
|
32
|
+
# @return [Hash, nil] { text: String, images: Array } or nil on EOF
|
|
33
|
+
def read_input(prefix: "❯")
|
|
34
|
+
@images = []
|
|
35
|
+
lines = []
|
|
36
|
+
cursor_pos = 0
|
|
37
|
+
line_index = 0
|
|
38
|
+
@last_ctrl_c_time = nil # Track when Ctrl+C was last pressed
|
|
39
|
+
|
|
40
|
+
loop do
|
|
41
|
+
# Display the prompt (simplified version)
|
|
42
|
+
display_simple_prompt(lines, prefix, line_index, cursor_pos)
|
|
43
|
+
|
|
44
|
+
# Read a single character/key
|
|
45
|
+
begin
|
|
46
|
+
key = read_key_with_rapid_detection
|
|
47
|
+
rescue Interrupt
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Handle buffered rapid input (system paste detection)
|
|
52
|
+
if key.is_a?(Hash) && key[:type] == :rapid_input
|
|
53
|
+
pasted_text = key[:text]
|
|
54
|
+
pasted_lines = pasted_text.split("\n")
|
|
55
|
+
|
|
56
|
+
if pasted_lines.size > 1
|
|
57
|
+
# Multi-line rapid input - use placeholder for display
|
|
58
|
+
@paste_counter += 1
|
|
59
|
+
placeholder = "[##{@paste_counter} Paste Text]"
|
|
60
|
+
@paste_placeholders[placeholder] = pasted_text
|
|
61
|
+
|
|
62
|
+
# Insert placeholder at cursor position
|
|
63
|
+
chars = (lines[line_index] || "").chars
|
|
64
|
+
placeholder_chars = placeholder.chars
|
|
65
|
+
chars.insert(cursor_pos, *placeholder_chars)
|
|
66
|
+
lines[line_index] = chars.join
|
|
67
|
+
cursor_pos += placeholder_chars.length
|
|
68
|
+
else
|
|
69
|
+
# Single line rapid input - insert at cursor (use chars for UTF-8)
|
|
70
|
+
chars = (lines[line_index] || "").chars
|
|
71
|
+
pasted_chars = pasted_text.chars
|
|
72
|
+
chars.insert(cursor_pos, *pasted_chars)
|
|
73
|
+
lines[line_index] = chars.join
|
|
74
|
+
cursor_pos += pasted_chars.length
|
|
75
|
+
end
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
case key
|
|
80
|
+
when "\n" # Shift+Enter - newline (Linux/Mac sends \n for Shift+Enter in some terminals)
|
|
81
|
+
# Add new line
|
|
82
|
+
if lines[line_index]
|
|
83
|
+
# Split current line at cursor (use chars for UTF-8)
|
|
84
|
+
chars = lines[line_index].chars
|
|
85
|
+
lines[line_index] = chars[0...cursor_pos].join
|
|
86
|
+
lines.insert(line_index + 1, chars[cursor_pos..-1].join || "")
|
|
87
|
+
else
|
|
88
|
+
lines.insert(line_index + 1, "")
|
|
89
|
+
end
|
|
90
|
+
line_index += 1
|
|
91
|
+
cursor_pos = 0
|
|
92
|
+
|
|
93
|
+
when "\r" # Enter - submit
|
|
94
|
+
# Submit if not empty
|
|
95
|
+
unless lines.join.strip.empty? && @images.empty?
|
|
96
|
+
clear_simple_prompt(lines.size)
|
|
97
|
+
# Replace placeholders with actual pasted content
|
|
98
|
+
final_text = expand_placeholders(lines.join("\n"))
|
|
99
|
+
return { text: final_text, images: @images.dup }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
when "\u0003" # Ctrl+C
|
|
103
|
+
# Check if input is empty
|
|
104
|
+
has_content = lines.any? { |line| !line.strip.empty? } || @images.any?
|
|
105
|
+
|
|
106
|
+
if has_content
|
|
107
|
+
# Input has content - clear it on first Ctrl+C
|
|
108
|
+
current_time = Time.now.to_f
|
|
109
|
+
time_since_last = @last_ctrl_c_time ? (current_time - @last_ctrl_c_time) : Float::INFINITY
|
|
110
|
+
|
|
111
|
+
if time_since_last < 2.0 # Within 2 seconds of last Ctrl+C
|
|
112
|
+
# Second Ctrl+C within 2 seconds - exit
|
|
113
|
+
clear_simple_prompt(lines.size)
|
|
114
|
+
return nil
|
|
115
|
+
else
|
|
116
|
+
# First Ctrl+C - clear content
|
|
117
|
+
@last_ctrl_c_time = current_time
|
|
118
|
+
lines = []
|
|
119
|
+
@images = []
|
|
120
|
+
cursor_pos = 0
|
|
121
|
+
line_index = 0
|
|
122
|
+
@paste_counter = 0
|
|
123
|
+
@paste_placeholders = {}
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
# Input is empty - exit immediately
|
|
127
|
+
clear_simple_prompt(lines.size)
|
|
128
|
+
return nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
when "\u0016" # Ctrl+V - Paste
|
|
132
|
+
pasted = paste_from_clipboard
|
|
133
|
+
if pasted[:type] == :image
|
|
134
|
+
# Save image and add to list
|
|
135
|
+
@images << pasted[:path]
|
|
136
|
+
else
|
|
137
|
+
# Handle pasted text
|
|
138
|
+
pasted_text = pasted[:text]
|
|
139
|
+
pasted_lines = pasted_text.split("\n")
|
|
140
|
+
|
|
141
|
+
if pasted_lines.size > 1
|
|
142
|
+
# Multi-line paste - use placeholder for display
|
|
143
|
+
@paste_counter += 1
|
|
144
|
+
placeholder = "[##{@paste_counter} Paste Text]"
|
|
145
|
+
@paste_placeholders[placeholder] = pasted_text
|
|
146
|
+
|
|
147
|
+
# Insert placeholder at cursor position
|
|
148
|
+
chars = (lines[line_index] || "").chars
|
|
149
|
+
placeholder_chars = placeholder.chars
|
|
150
|
+
chars.insert(cursor_pos, *placeholder_chars)
|
|
151
|
+
lines[line_index] = chars.join
|
|
152
|
+
cursor_pos += placeholder_chars.length
|
|
153
|
+
else
|
|
154
|
+
# Single line paste - insert at cursor (use chars for UTF-8)
|
|
155
|
+
chars = (lines[line_index] || "").chars
|
|
156
|
+
pasted_chars = pasted_text.chars
|
|
157
|
+
chars.insert(cursor_pos, *pasted_chars)
|
|
158
|
+
lines[line_index] = chars.join
|
|
159
|
+
cursor_pos += pasted_chars.length
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
when "\u007F", "\b" # Backspace
|
|
164
|
+
if cursor_pos > 0
|
|
165
|
+
# Delete character before cursor (use chars for UTF-8)
|
|
166
|
+
chars = (lines[line_index] || "").chars
|
|
167
|
+
chars.delete_at(cursor_pos - 1)
|
|
168
|
+
lines[line_index] = chars.join
|
|
169
|
+
cursor_pos -= 1
|
|
170
|
+
elsif line_index > 0
|
|
171
|
+
# Join with previous line
|
|
172
|
+
prev_line = lines[line_index - 1]
|
|
173
|
+
current_line = lines[line_index]
|
|
174
|
+
lines.delete_at(line_index)
|
|
175
|
+
line_index -= 1
|
|
176
|
+
cursor_pos = prev_line.chars.length
|
|
177
|
+
lines[line_index] = prev_line + current_line
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
when "\e[A" # Up arrow
|
|
181
|
+
if line_index > 0
|
|
182
|
+
line_index -= 1
|
|
183
|
+
cursor_pos = [cursor_pos, (lines[line_index] || "").chars.length].min
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
when "\e[B" # Down arrow
|
|
187
|
+
if line_index < lines.size - 1
|
|
188
|
+
line_index += 1
|
|
189
|
+
cursor_pos = [cursor_pos, (lines[line_index] || "").chars.length].min
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
when "\e[C" # Right arrow
|
|
193
|
+
current_line = lines[line_index] || ""
|
|
194
|
+
cursor_pos = [cursor_pos + 1, current_line.chars.length].min
|
|
195
|
+
|
|
196
|
+
when "\e[D" # Left arrow
|
|
197
|
+
cursor_pos = [cursor_pos - 1, 0].max
|
|
198
|
+
|
|
199
|
+
when "\u0004" # Ctrl+D - Delete image by number
|
|
200
|
+
if @images.any?
|
|
201
|
+
print "\nEnter image number to delete (1-#{@images.size}): "
|
|
202
|
+
num = STDIN.gets.to_i
|
|
203
|
+
if num > 0 && num <= @images.size
|
|
204
|
+
@images.delete_at(num - 1)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
else
|
|
209
|
+
# Regular character input - support UTF-8
|
|
210
|
+
if key.length >= 1 && key != "\e" && !key.start_with?("\e") && key.ord >= 32
|
|
211
|
+
lines[line_index] ||= ""
|
|
212
|
+
current_line = lines[line_index]
|
|
213
|
+
|
|
214
|
+
# Insert character at cursor position (using character index, not byte index)
|
|
215
|
+
chars = current_line.chars
|
|
216
|
+
chars.insert(cursor_pos, key)
|
|
217
|
+
lines[line_index] = chars.join
|
|
218
|
+
cursor_pos += 1
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Ensure we have at least one line
|
|
223
|
+
lines << "" if lines.empty?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
private
|
|
228
|
+
|
|
229
|
+
# Display simplified prompt (just prefix and input, no box)
|
|
230
|
+
def display_simple_prompt(lines, prefix, line_index, cursor_pos)
|
|
231
|
+
# Clear previous display if exists
|
|
232
|
+
if @last_display_lines && @last_display_lines > 0
|
|
233
|
+
@last_display_lines.times do
|
|
234
|
+
print "\e[1A" # Move up one line
|
|
235
|
+
print "\e[2K" # Clear entire line
|
|
236
|
+
end
|
|
237
|
+
print "\r" # Move to beginning of line
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
lines_to_display = []
|
|
241
|
+
|
|
242
|
+
# Get terminal width for full-width separator
|
|
243
|
+
term_width = TTY::Screen.width
|
|
244
|
+
|
|
245
|
+
# Top separator line (full width)
|
|
246
|
+
lines_to_display << @pastel.dim("─" * term_width)
|
|
247
|
+
|
|
248
|
+
# Show images if any
|
|
249
|
+
if @images.any?
|
|
250
|
+
@images.each_with_index do |img_path, idx|
|
|
251
|
+
filename = File.basename(img_path)
|
|
252
|
+
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
253
|
+
lines_to_display << @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize})")
|
|
254
|
+
end
|
|
255
|
+
lines_to_display << ""
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Display input lines
|
|
259
|
+
display_lines = lines.empty? ? [""] : lines
|
|
260
|
+
|
|
261
|
+
display_lines.each_with_index do |line, idx|
|
|
262
|
+
if idx == 0
|
|
263
|
+
# First line with prefix
|
|
264
|
+
if idx == line_index
|
|
265
|
+
# Show cursor on this line
|
|
266
|
+
chars = line.chars
|
|
267
|
+
before_cursor = chars[0...cursor_pos].join
|
|
268
|
+
cursor_char = chars[cursor_pos] || " "
|
|
269
|
+
after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
|
|
270
|
+
|
|
271
|
+
line_display = "#{prefix} #{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
|
|
272
|
+
lines_to_display << line_display
|
|
273
|
+
else
|
|
274
|
+
lines_to_display << "#{prefix} #{line}"
|
|
275
|
+
end
|
|
276
|
+
else
|
|
277
|
+
# Continuation lines (indented to align with first line content)
|
|
278
|
+
indent = " " * (prefix.length + 1)
|
|
279
|
+
if idx == line_index
|
|
280
|
+
# Show cursor on this line
|
|
281
|
+
chars = line.chars
|
|
282
|
+
before_cursor = chars[0...cursor_pos].join
|
|
283
|
+
cursor_char = chars[cursor_pos] || " "
|
|
284
|
+
after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
|
|
285
|
+
|
|
286
|
+
line_display = "#{indent}#{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
|
|
287
|
+
lines_to_display << line_display
|
|
288
|
+
else
|
|
289
|
+
lines_to_display << "#{indent}#{line}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Bottom separator line (full width)
|
|
295
|
+
lines_to_display << @pastel.dim("─" * term_width)
|
|
296
|
+
|
|
297
|
+
# Output all lines
|
|
298
|
+
print lines_to_display.join("\n")
|
|
299
|
+
print "\n"
|
|
300
|
+
|
|
301
|
+
# Remember how many lines we displayed
|
|
302
|
+
@last_display_lines = lines_to_display.size
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Clear simple prompt display
|
|
306
|
+
def clear_simple_prompt(num_lines)
|
|
307
|
+
if @last_display_lines && @last_display_lines > 0
|
|
308
|
+
@last_display_lines.times do
|
|
309
|
+
print "\e[1A" # Move up one line
|
|
310
|
+
print "\e[2K" # Clear entire line
|
|
311
|
+
end
|
|
312
|
+
print "\r" # Move to beginning of line
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Expand placeholders to actual pasted content
|
|
317
|
+
def expand_placeholders(text)
|
|
318
|
+
result = text.dup
|
|
319
|
+
@paste_placeholders.each do |placeholder, actual_content|
|
|
320
|
+
result.gsub!(placeholder, actual_content)
|
|
321
|
+
end
|
|
322
|
+
result
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Read a single key press with escape sequence handling
|
|
326
|
+
# Handles UTF-8 multi-byte characters correctly
|
|
327
|
+
# Also detects rapid input (paste-like behavior)
|
|
328
|
+
def read_key_with_rapid_detection
|
|
329
|
+
$stdin.set_encoding('UTF-8')
|
|
330
|
+
|
|
331
|
+
current_time = Time.now.to_f
|
|
332
|
+
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
333
|
+
@last_input_time = current_time
|
|
334
|
+
|
|
335
|
+
$stdin.raw do |io|
|
|
336
|
+
io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
|
|
337
|
+
c = io.getc
|
|
338
|
+
|
|
339
|
+
# Ensure character is UTF-8 encoded
|
|
340
|
+
c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
|
|
341
|
+
|
|
342
|
+
# Handle escape sequences (arrow keys, special keys)
|
|
343
|
+
if c == "\e"
|
|
344
|
+
# Read the next 2 characters for escape sequences
|
|
345
|
+
begin
|
|
346
|
+
extra = io.read_nonblock(2)
|
|
347
|
+
extra = extra.force_encoding('UTF-8') if extra.encoding != Encoding::UTF_8
|
|
348
|
+
c = c + extra
|
|
349
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
|
350
|
+
# No more characters available
|
|
351
|
+
end
|
|
352
|
+
return c
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Check if there are more characters available using IO.select with timeout 0
|
|
356
|
+
has_more_input = IO.select([io], nil, nil, 0)
|
|
357
|
+
|
|
358
|
+
# If this is rapid input or there are more characters available
|
|
359
|
+
if is_rapid_input || has_more_input
|
|
360
|
+
# Buffer rapid input
|
|
361
|
+
buffer = c.to_s.dup
|
|
362
|
+
buffer.force_encoding('UTF-8')
|
|
363
|
+
|
|
364
|
+
# Keep reading available characters
|
|
365
|
+
loop do
|
|
366
|
+
begin
|
|
367
|
+
next_char = io.read_nonblock(1)
|
|
368
|
+
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
369
|
+
buffer << next_char
|
|
370
|
+
|
|
371
|
+
# Continue only if more characters are immediately available
|
|
372
|
+
break unless IO.select([io], nil, nil, 0)
|
|
373
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
|
374
|
+
break
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Ensure buffer is UTF-8
|
|
379
|
+
buffer.force_encoding('UTF-8')
|
|
380
|
+
|
|
381
|
+
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
382
|
+
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
383
|
+
# Remove any trailing \r or \n from rapid input buffer
|
|
384
|
+
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
385
|
+
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Single character rapid input, return as-is
|
|
389
|
+
return buffer[0] if buffer.length == 1
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
c
|
|
393
|
+
end
|
|
394
|
+
rescue Errno::EINTR
|
|
395
|
+
"\u0003" # Treat interrupt as Ctrl+C
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Legacy method for compatibility
|
|
399
|
+
def read_key
|
|
400
|
+
read_key_with_rapid_detection
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Paste from clipboard (cross-platform)
|
|
404
|
+
# @return [Hash] { type: :text/:image, text: String, path: String }
|
|
405
|
+
def paste_from_clipboard
|
|
406
|
+
case RbConfig::CONFIG["host_os"]
|
|
407
|
+
when /darwin/i
|
|
408
|
+
paste_from_clipboard_macos
|
|
409
|
+
when /linux/i
|
|
410
|
+
paste_from_clipboard_linux
|
|
411
|
+
when /mswin|mingw|cygwin/i
|
|
412
|
+
paste_from_clipboard_windows
|
|
413
|
+
else
|
|
414
|
+
{ type: :text, text: "" }
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Paste from macOS clipboard
|
|
419
|
+
def paste_from_clipboard_macos
|
|
420
|
+
require 'shellwords'
|
|
421
|
+
require 'fileutils'
|
|
422
|
+
|
|
423
|
+
# First check if there's an image in clipboard
|
|
424
|
+
# Use osascript to check clipboard content type
|
|
425
|
+
has_image = system("osascript -e 'try' -e 'the clipboard as «class PNGf»' -e 'on error' -e 'return false' -e 'end try' >/dev/null 2>&1")
|
|
426
|
+
|
|
427
|
+
if has_image
|
|
428
|
+
# Create a persistent temporary file (won't be auto-deleted)
|
|
429
|
+
temp_dir = Dir.tmpdir
|
|
430
|
+
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
431
|
+
temp_path = File.join(temp_dir, temp_filename)
|
|
432
|
+
|
|
433
|
+
# Extract image using osascript
|
|
434
|
+
script = <<~APPLESCRIPT
|
|
435
|
+
set png_data to the clipboard as «class PNGf»
|
|
436
|
+
set the_file to open for access POSIX file "#{temp_path}" with write permission
|
|
437
|
+
write png_data to the_file
|
|
438
|
+
close access the_file
|
|
439
|
+
APPLESCRIPT
|
|
440
|
+
|
|
441
|
+
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
442
|
+
|
|
443
|
+
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
444
|
+
return { type: :image, path: temp_path }
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# No image, try text - ensure UTF-8 encoding
|
|
449
|
+
text = `pbpaste 2>/dev/null`.to_s
|
|
450
|
+
text.force_encoding('UTF-8')
|
|
451
|
+
# Replace invalid UTF-8 sequences with replacement character
|
|
452
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
453
|
+
{ type: :text, text: text }
|
|
454
|
+
rescue => e
|
|
455
|
+
# Fallback to empty text on error
|
|
456
|
+
{ type: :text, text: "" }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Paste from Linux clipboard
|
|
460
|
+
def paste_from_clipboard_linux
|
|
461
|
+
require 'shellwords'
|
|
462
|
+
|
|
463
|
+
# Check if xclip is available
|
|
464
|
+
if system("which xclip >/dev/null 2>&1")
|
|
465
|
+
# Try to get image first
|
|
466
|
+
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
467
|
+
temp_file.close
|
|
468
|
+
|
|
469
|
+
# Try different image MIME types
|
|
470
|
+
["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
|
|
471
|
+
if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
|
|
472
|
+
if File.size(temp_file.path) > 0
|
|
473
|
+
return { type: :image, path: temp_file.path }
|
|
474
|
+
end
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# No image, get text - ensure UTF-8 encoding
|
|
479
|
+
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
480
|
+
text.force_encoding('UTF-8')
|
|
481
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
482
|
+
{ type: :text, text: text }
|
|
483
|
+
elsif system("which xsel >/dev/null 2>&1")
|
|
484
|
+
# Fallback to xsel for text only
|
|
485
|
+
text = `xsel --clipboard --output 2>/dev/null`.to_s
|
|
486
|
+
text.force_encoding('UTF-8')
|
|
487
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
488
|
+
{ type: :text, text: text }
|
|
489
|
+
else
|
|
490
|
+
{ type: :text, text: "" }
|
|
491
|
+
end
|
|
492
|
+
rescue => e
|
|
493
|
+
{ type: :text, text: "" }
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Paste from Windows clipboard
|
|
497
|
+
def paste_from_clipboard_windows
|
|
498
|
+
# Try to get image using PowerShell
|
|
499
|
+
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
500
|
+
temp_file.close
|
|
501
|
+
|
|
502
|
+
ps_script = <<~POWERSHELL
|
|
503
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
504
|
+
$img = [Windows.Forms.Clipboard]::GetImage()
|
|
505
|
+
if ($img) {
|
|
506
|
+
$img.Save('#{temp_file.path.gsub("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
507
|
+
exit 0
|
|
508
|
+
} else {
|
|
509
|
+
exit 1
|
|
510
|
+
}
|
|
511
|
+
POWERSHELL
|
|
512
|
+
|
|
513
|
+
success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
|
|
514
|
+
|
|
515
|
+
if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
|
|
516
|
+
return { type: :image, path: temp_file.path }
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# No image, get text - ensure UTF-8 encoding
|
|
520
|
+
text = `powershell -NoProfile -Command "Get-Clipboard" 2>nul`.to_s
|
|
521
|
+
text.force_encoding('UTF-8')
|
|
522
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
523
|
+
{ type: :text, text: text }
|
|
524
|
+
rescue => e
|
|
525
|
+
{ type: :text, text: "" }
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Format file size for display
|
|
529
|
+
def format_filesize(size)
|
|
530
|
+
if size < 1024
|
|
531
|
+
"#{size}B"
|
|
532
|
+
elsif size < 1024 * 1024
|
|
533
|
+
"#{(size / 1024.0).round(1)}KB"
|
|
534
|
+
else
|
|
535
|
+
"#{(size / 1024.0 / 1024.0).round(1)}MB"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
end
|
data/lib/clacky/ui/statusbar.rb
CHANGED
|
@@ -45,14 +45,12 @@ module Clacky
|
|
|
45
45
|
status_line = " " + parts.join(separator)
|
|
46
46
|
|
|
47
47
|
puts status_line
|
|
48
|
-
puts @pastel.dim("─" * [TTY::Screen.width, 80].min)
|
|
49
48
|
end
|
|
50
49
|
|
|
51
50
|
# Display minimal status for non-interactive mode
|
|
52
51
|
def display_minimal(working_dir:, mode:)
|
|
53
52
|
dir_display = shorten_path(working_dir)
|
|
54
53
|
puts " #{@pastel.bright_cyan(dir_display)} #{@pastel.dim('│')} #{@pastel.yellow(mode)}"
|
|
55
|
-
puts @pastel.dim("─" * [TTY::Screen.width, 80].min)
|
|
56
54
|
end
|
|
57
55
|
|
|
58
56
|
private
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky.rb
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
require_relative "clacky/version"
|
|
4
4
|
require_relative "clacky/config"
|
|
5
5
|
require_relative "clacky/client"
|
|
6
|
-
require_relative "clacky/conversation"
|
|
7
6
|
|
|
8
7
|
# Agent system
|
|
8
|
+
require_relative "clacky/model_pricing"
|
|
9
9
|
require_relative "clacky/agent_config"
|
|
10
10
|
require_relative "clacky/hook_manager"
|
|
11
11
|
require_relative "clacky/tool_registry"
|
|
12
12
|
require_relative "clacky/thinking_verbs"
|
|
13
13
|
require_relative "clacky/progress_indicator"
|
|
14
14
|
require_relative "clacky/session_manager"
|
|
15
|
+
require_relative "clacky/gitignore_parser"
|
|
15
16
|
require_relative "clacky/utils/limit_stack"
|
|
16
17
|
require_relative "clacky/utils/path_helper"
|
|
17
18
|
require_relative "clacky/tools/base"
|
|
@@ -32,7 +33,7 @@ require_relative "clacky/agent"
|
|
|
32
33
|
|
|
33
34
|
# UI components
|
|
34
35
|
require_relative "clacky/ui/banner"
|
|
35
|
-
require_relative "clacky/ui/
|
|
36
|
+
require_relative "clacky/ui/enhanced_prompt"
|
|
36
37
|
require_relative "clacky/ui/statusbar"
|
|
37
38
|
require_relative "clacky/ui/formatter"
|
|
38
39
|
|
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.5.
|
|
4
|
+
version: 0.5.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -136,8 +136,9 @@ files:
|
|
|
136
136
|
- lib/clacky/cli.rb
|
|
137
137
|
- lib/clacky/client.rb
|
|
138
138
|
- lib/clacky/config.rb
|
|
139
|
-
- lib/clacky/
|
|
139
|
+
- lib/clacky/gitignore_parser.rb
|
|
140
140
|
- lib/clacky/hook_manager.rb
|
|
141
|
+
- lib/clacky/model_pricing.rb
|
|
141
142
|
- lib/clacky/progress_indicator.rb
|
|
142
143
|
- lib/clacky/session_manager.rb
|
|
143
144
|
- lib/clacky/thinking_verbs.rb
|
|
@@ -157,8 +158,8 @@ files:
|
|
|
157
158
|
- lib/clacky/tools/write.rb
|
|
158
159
|
- lib/clacky/trash_directory.rb
|
|
159
160
|
- lib/clacky/ui/banner.rb
|
|
161
|
+
- lib/clacky/ui/enhanced_prompt.rb
|
|
160
162
|
- lib/clacky/ui/formatter.rb
|
|
161
|
-
- lib/clacky/ui/prompt.rb
|
|
162
163
|
- lib/clacky/ui/statusbar.rb
|
|
163
164
|
- lib/clacky/utils/arguments_parser.rb
|
|
164
165
|
- lib/clacky/utils/limit_stack.rb
|
data/lib/clacky/conversation.rb
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Clacky
|
|
4
|
-
class Conversation
|
|
5
|
-
attr_reader :messages
|
|
6
|
-
|
|
7
|
-
def initialize(api_key, model:, base_url:, max_tokens:)
|
|
8
|
-
@client = Client.new(api_key, base_url: base_url)
|
|
9
|
-
@model = model
|
|
10
|
-
@max_tokens = max_tokens
|
|
11
|
-
@messages = []
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def send_message(content)
|
|
15
|
-
# Add user message to history
|
|
16
|
-
@messages << {
|
|
17
|
-
role: "user",
|
|
18
|
-
content: content
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
# Get response from Claude
|
|
22
|
-
response_text = @client.send_messages(@messages, model: @model, max_tokens: @max_tokens)
|
|
23
|
-
|
|
24
|
-
# Add assistant response to history
|
|
25
|
-
@messages << {
|
|
26
|
-
role: "assistant",
|
|
27
|
-
content: response_text
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
response_text
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def clear
|
|
34
|
-
@messages = []
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def history
|
|
38
|
-
@messages.dup
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|