openclacky 0.5.5 → 0.5.6
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/.clackyrules +4 -0
- data/README.md +1 -1
- data/lib/clacky/agent.rb +158 -5
- data/lib/clacky/cli.rb +79 -11
- data/lib/clacky/progress_indicator.rb +1 -1
- data/lib/clacky/tools/file_reader.rb +73 -10
- data/lib/clacky/tools/grep.rb +44 -0
- data/lib/clacky/ui/enhanced_prompt.rb +317 -71
- data/lib/clacky/ui/formatter.rb +1 -1
- data/lib/clacky/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 34f843ea474327ea95f88685111f1a86149730503625c5e971de239e63764969
|
|
4
|
+
data.tar.gz: 85f2e93d791d3f774aa4f0d29177d4999fd91306f3c441f21d89c11ab37f15d0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8bcb13d8cb5ee790ff698fc9bd0be4d2b366e1d16216db6a2f24e56c0dc1954c3b82349473b49cdee7c66ae732e3b684ee1d249d54dabe0f7d9cb0cf1f59873b
|
|
7
|
+
data.tar.gz: 866e043424ca831364baba5a842b5554cc1da2309e2935a0458edbead75c5e89f9cdf146506df12fc04c87d78c5613e3b2e812b13a816864af5861b1f815c788
|
data/.clackyrules
CHANGED
|
@@ -25,6 +25,7 @@ It provides chat functionality and autonomous AI agent capabilities with tool us
|
|
|
25
25
|
- **IMPORTANT**: All code comments must be written in English
|
|
26
26
|
- Add descriptive comments for complex logic
|
|
27
27
|
- Use clear, self-documenting code with English naming
|
|
28
|
+
- **IMPORTANT**: Always use inline `private` with method definitions (e.g., `private def method_name`). Do NOT use standalone `private` keyword
|
|
28
29
|
|
|
29
30
|
### Architecture Patterns
|
|
30
31
|
- Tools inherit from `Clacky::Tools::Base`
|
|
@@ -41,6 +42,9 @@ It provides chat functionality and autonomous AI agent capabilities with tool us
|
|
|
41
42
|
- Maintain good test coverage
|
|
42
43
|
- When modifying existing functionality, ensure related tests still pass
|
|
43
44
|
- When adding new features, consider adding corresponding tests
|
|
45
|
+
- **IMPORTANT**: When developing new features, write RSpec tests as needed and ensure they pass
|
|
46
|
+
- **DO NOT** write custom test scripts unless explicitly requested by the user
|
|
47
|
+
- **DO NOT** create markdown documentation unless explicitly requested by the user
|
|
44
48
|
|
|
45
49
|
### Tool Development
|
|
46
50
|
When adding new tools:
|
data/README.md
CHANGED
data/lib/clacky/agent.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "securerandom"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "tty-prompt"
|
|
6
6
|
require "set"
|
|
7
|
+
require "base64"
|
|
7
8
|
require_relative "utils/arguments_parser"
|
|
8
9
|
|
|
9
10
|
module Clacky
|
|
@@ -72,6 +73,7 @@ module Clacky
|
|
|
72
73
|
@total_tasks = 0
|
|
73
74
|
@cost_source = :estimated # Track whether cost is from API or estimated
|
|
74
75
|
@task_cost_source = :estimated # Track cost source for current task
|
|
76
|
+
@previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
|
|
75
77
|
|
|
76
78
|
# Register built-in tools
|
|
77
79
|
register_builtin_tools
|
|
@@ -127,9 +129,10 @@ module Clacky
|
|
|
127
129
|
@hooks.add(event, &block)
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
def run(user_input, &block)
|
|
132
|
+
def run(user_input, images: [], &block)
|
|
131
133
|
@start_time = Time.now
|
|
132
134
|
@task_cost_source = :estimated # Reset for new task
|
|
135
|
+
@previous_total_tokens = 0 # Reset token tracking for new task
|
|
133
136
|
|
|
134
137
|
# Add system prompt as the first message if this is the first run
|
|
135
138
|
if @messages.empty?
|
|
@@ -144,7 +147,9 @@ module Clacky
|
|
|
144
147
|
@messages << system_message
|
|
145
148
|
end
|
|
146
149
|
|
|
147
|
-
|
|
150
|
+
# Format user message with images if provided
|
|
151
|
+
user_content = format_user_content(user_input, images)
|
|
152
|
+
@messages << { role: "user", content: user_content }
|
|
148
153
|
@total_tasks += 1
|
|
149
154
|
|
|
150
155
|
emit_event(:on_start, { input: user_input }, &block)
|
|
@@ -611,22 +616,25 @@ module Clacky
|
|
|
611
616
|
|
|
612
617
|
def track_cost(usage)
|
|
613
618
|
# Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
|
|
619
|
+
iteration_cost = nil
|
|
614
620
|
if usage[:api_cost]
|
|
615
621
|
@total_cost += usage[:api_cost]
|
|
616
622
|
@cost_source = :api
|
|
617
623
|
@task_cost_source = :api
|
|
624
|
+
iteration_cost = usage[:api_cost]
|
|
618
625
|
puts "[DEBUG] Using API-provided cost: $#{usage[:api_cost]}" if @config.verbose
|
|
619
626
|
else
|
|
620
627
|
# Priority 2: Calculate from tokens using ModelPricing
|
|
621
628
|
result = ModelPricing.calculate_cost(model: @config.model, usage: usage)
|
|
622
629
|
cost = result[:cost]
|
|
623
630
|
pricing_source = result[:source]
|
|
624
|
-
|
|
631
|
+
|
|
625
632
|
@total_cost += cost
|
|
633
|
+
iteration_cost = cost
|
|
626
634
|
# Map pricing source to cost source: :price or :default
|
|
627
635
|
@cost_source = pricing_source
|
|
628
636
|
@task_cost_source = pricing_source
|
|
629
|
-
|
|
637
|
+
|
|
630
638
|
if @config.verbose
|
|
631
639
|
source_label = pricing_source == :price ? "model pricing" : "default pricing"
|
|
632
640
|
puts "[DEBUG] Calculated cost for #{@config.model} using #{source_label}: $#{cost.round(6)}"
|
|
@@ -634,6 +642,9 @@ module Clacky
|
|
|
634
642
|
end
|
|
635
643
|
end
|
|
636
644
|
|
|
645
|
+
# Display token usage statistics for this iteration
|
|
646
|
+
display_iteration_tokens(usage, iteration_cost)
|
|
647
|
+
|
|
637
648
|
# Track cache usage statistics
|
|
638
649
|
@cache_stats[:total_requests] += 1
|
|
639
650
|
|
|
@@ -647,6 +658,66 @@ module Clacky
|
|
|
647
658
|
end
|
|
648
659
|
end
|
|
649
660
|
|
|
661
|
+
# Display token usage for current iteration
|
|
662
|
+
private def display_iteration_tokens(usage, cost)
|
|
663
|
+
prompt_tokens = usage[:prompt_tokens] || 0
|
|
664
|
+
completion_tokens = usage[:completion_tokens] || 0
|
|
665
|
+
total_tokens = usage[:total_tokens] || (prompt_tokens + completion_tokens)
|
|
666
|
+
cache_write = usage[:cache_creation_input_tokens] || 0
|
|
667
|
+
cache_read = usage[:cache_read_input_tokens] || 0
|
|
668
|
+
|
|
669
|
+
# Calculate token delta from previous iteration
|
|
670
|
+
delta_tokens = total_tokens - @previous_total_tokens
|
|
671
|
+
@previous_total_tokens = total_tokens # Update for next iteration
|
|
672
|
+
|
|
673
|
+
# Build token summary string
|
|
674
|
+
token_info = []
|
|
675
|
+
|
|
676
|
+
# Delta tokens with color coding at the beginning
|
|
677
|
+
require 'pastel'
|
|
678
|
+
pastel = Pastel.new
|
|
679
|
+
|
|
680
|
+
delta_str = "+#{delta_tokens}"
|
|
681
|
+
colored_delta = if delta_tokens > 10000
|
|
682
|
+
pastel.red.bold(delta_str) # Error level: red for > 10k
|
|
683
|
+
elsif delta_tokens > 5000
|
|
684
|
+
pastel.yellow.bold(delta_str) # Warn level: yellow for > 5k
|
|
685
|
+
else
|
|
686
|
+
pastel.green(delta_str) # Normal: green for <= 5k
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
token_info << colored_delta
|
|
690
|
+
|
|
691
|
+
# Cache status indicator
|
|
692
|
+
cache_used = cache_read > 0 || cache_write > 0
|
|
693
|
+
if cache_used
|
|
694
|
+
cache_indicator = "✓ Cached"
|
|
695
|
+
token_info << pastel.cyan(cache_indicator)
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# Input tokens (with cache breakdown if available)
|
|
699
|
+
if cache_write > 0 || cache_read > 0
|
|
700
|
+
input_detail = "#{prompt_tokens} (cache: #{cache_read} read, #{cache_write} write)"
|
|
701
|
+
token_info << "Input: #{input_detail}"
|
|
702
|
+
else
|
|
703
|
+
token_info << "Input: #{prompt_tokens}"
|
|
704
|
+
end
|
|
705
|
+
|
|
706
|
+
# Output tokens
|
|
707
|
+
token_info << "Output: #{completion_tokens}"
|
|
708
|
+
|
|
709
|
+
# Total
|
|
710
|
+
token_info << "Total: #{total_tokens}"
|
|
711
|
+
|
|
712
|
+
# Cost for this iteration
|
|
713
|
+
if cost
|
|
714
|
+
token_info << "Cost: $#{cost.round(6)}"
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
# Display with color
|
|
718
|
+
puts pastel.dim(" [Tokens] #{token_info.join(' | ')}")
|
|
719
|
+
end
|
|
720
|
+
|
|
650
721
|
def compress_messages_if_needed
|
|
651
722
|
# Check if compression is enabled
|
|
652
723
|
return unless @config.enable_compression
|
|
@@ -1055,9 +1126,20 @@ module Clacky
|
|
|
1055
1126
|
end
|
|
1056
1127
|
|
|
1057
1128
|
def build_success_result(call, result)
|
|
1129
|
+
# Try to get tool instance to use its format_result_for_llm method
|
|
1130
|
+
tool = @tool_registry.get(call[:name]) rescue nil
|
|
1131
|
+
|
|
1132
|
+
formatted_result = if tool && tool.respond_to?(:format_result_for_llm)
|
|
1133
|
+
# Tool provides a custom LLM-friendly format
|
|
1134
|
+
tool.format_result_for_llm(result)
|
|
1135
|
+
else
|
|
1136
|
+
# Fallback: use the original result
|
|
1137
|
+
result
|
|
1138
|
+
end
|
|
1139
|
+
|
|
1058
1140
|
{
|
|
1059
1141
|
id: call[:id],
|
|
1060
|
-
content: JSON.generate(
|
|
1142
|
+
content: JSON.generate(formatted_result)
|
|
1061
1143
|
}
|
|
1062
1144
|
end
|
|
1063
1145
|
|
|
@@ -1133,5 +1215,76 @@ module Clacky
|
|
|
1133
1215
|
@tool_registry.register(Tools::TodoManager.new)
|
|
1134
1216
|
@tool_registry.register(Tools::RunProject.new)
|
|
1135
1217
|
end
|
|
1218
|
+
|
|
1219
|
+
# Format user content with optional images
|
|
1220
|
+
# @param text [String] User's text input
|
|
1221
|
+
# @param images [Array<String>] Array of image file paths
|
|
1222
|
+
# @return [String|Array] String if no images, Array with text and image_url objects if images present
|
|
1223
|
+
def format_user_content(text, images)
|
|
1224
|
+
return text if images.nil? || images.empty?
|
|
1225
|
+
|
|
1226
|
+
content = []
|
|
1227
|
+
content << { type: "text", text: text } unless text.nil? || text.empty?
|
|
1228
|
+
|
|
1229
|
+
images.each do |image_path|
|
|
1230
|
+
image_url = image_path_to_data_url(image_path)
|
|
1231
|
+
content << { type: "image_url", image_url: { url: image_url } }
|
|
1232
|
+
end
|
|
1233
|
+
|
|
1234
|
+
content
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
# Convert image file path to base64 data URL
|
|
1238
|
+
# @param path [String] File path to image
|
|
1239
|
+
# @return [String] base64 data URL (e.g., "data:image/png;base64,...")
|
|
1240
|
+
def image_path_to_data_url(path)
|
|
1241
|
+
unless File.exist?(path)
|
|
1242
|
+
raise ArgumentError, "Image file not found: #{path}"
|
|
1243
|
+
end
|
|
1244
|
+
|
|
1245
|
+
# Read file as binary
|
|
1246
|
+
image_data = File.binread(path)
|
|
1247
|
+
|
|
1248
|
+
# Detect MIME type from file extension or content
|
|
1249
|
+
mime_type = detect_image_mime_type(path, image_data)
|
|
1250
|
+
|
|
1251
|
+
# Encode to base64
|
|
1252
|
+
base64_data = Base64.strict_encode64(image_data)
|
|
1253
|
+
|
|
1254
|
+
"data:#{mime_type};base64,#{base64_data}"
|
|
1255
|
+
end
|
|
1256
|
+
|
|
1257
|
+
# Detect image MIME type
|
|
1258
|
+
# @param path [String] File path
|
|
1259
|
+
# @param data [String] Binary image data
|
|
1260
|
+
# @return [String] MIME type (e.g., "image/png")
|
|
1261
|
+
def detect_image_mime_type(path, data)
|
|
1262
|
+
# Try to detect from file extension first
|
|
1263
|
+
ext = File.extname(path).downcase
|
|
1264
|
+
case ext
|
|
1265
|
+
when ".png"
|
|
1266
|
+
"image/png"
|
|
1267
|
+
when ".jpg", ".jpeg"
|
|
1268
|
+
"image/jpeg"
|
|
1269
|
+
when ".gif"
|
|
1270
|
+
"image/gif"
|
|
1271
|
+
when ".webp"
|
|
1272
|
+
"image/webp"
|
|
1273
|
+
else
|
|
1274
|
+
# Try to detect from file signature (magic bytes)
|
|
1275
|
+
if data.start_with?("\x89PNG".b)
|
|
1276
|
+
"image/png"
|
|
1277
|
+
elsif data.start_with?("\xFF\xD8\xFF".b)
|
|
1278
|
+
"image/jpeg"
|
|
1279
|
+
elsif data.start_with?("GIF87a".b) || data.start_with?("GIF89a".b)
|
|
1280
|
+
"image/gif"
|
|
1281
|
+
elsif data.start_with?("RIFF".b) && data[8..11] == "WEBP".b
|
|
1282
|
+
"image/webp"
|
|
1283
|
+
else
|
|
1284
|
+
# Default to png if unknown
|
|
1285
|
+
"image/png"
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1136
1289
|
end
|
|
1137
1290
|
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -107,7 +107,7 @@ module Clacky
|
|
|
107
107
|
|
|
108
108
|
begin
|
|
109
109
|
# Always run in interactive mode
|
|
110
|
-
run_agent_interactive(agent, working_dir, agent_config, message, session_manager)
|
|
110
|
+
run_agent_interactive(agent, working_dir, agent_config, message, session_manager, client)
|
|
111
111
|
rescue StandardError => e
|
|
112
112
|
# Save session on error
|
|
113
113
|
if session_manager
|
|
@@ -331,7 +331,7 @@ module Clacky
|
|
|
331
331
|
end
|
|
332
332
|
end
|
|
333
333
|
|
|
334
|
-
def run_agent_interactive(agent, working_dir, agent_config, initial_message = nil, session_manager = nil)
|
|
334
|
+
def run_agent_interactive(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil)
|
|
335
335
|
# Store agent as instance variable for access in display methods
|
|
336
336
|
@current_agent = agent
|
|
337
337
|
|
|
@@ -371,10 +371,13 @@ module Clacky
|
|
|
371
371
|
|
|
372
372
|
# Process initial message if provided
|
|
373
373
|
current_message = initial_message
|
|
374
|
+
current_images = []
|
|
374
375
|
|
|
375
376
|
loop do
|
|
376
377
|
# Get message from user if not provided
|
|
377
378
|
unless current_message && !current_message.strip.empty?
|
|
379
|
+
# Only show newline separator if we've completed tasks
|
|
380
|
+
# (but not right after /clear since we just showed a message)
|
|
378
381
|
say "\n" if total_tasks > 0
|
|
379
382
|
|
|
380
383
|
# Show status bar before input
|
|
@@ -387,23 +390,86 @@ module Clacky
|
|
|
387
390
|
)
|
|
388
391
|
|
|
389
392
|
# Use enhanced prompt with "❯" prefix
|
|
390
|
-
result = prompt.read_input(prefix: "❯")
|
|
393
|
+
result = prompt.read_input(prefix: "❯") do |display_lines|
|
|
394
|
+
# Shift+Tab pressed - toggle mode and update status bar
|
|
395
|
+
if agent_config.permission_mode == :confirm_safes
|
|
396
|
+
agent_config.permission_mode = :auto_approve
|
|
397
|
+
else
|
|
398
|
+
agent_config.permission_mode = :confirm_safes
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Update status bar (it's above the input box)
|
|
402
|
+
# display_lines includes the final newline, so we need display_lines moves to reach status bar
|
|
403
|
+
print "\e[#{display_lines}A" # Move up to status bar line
|
|
404
|
+
print "\r\e[2K" # Clear the status bar line
|
|
405
|
+
|
|
406
|
+
# Redisplay status bar with new mode (puts adds newline, cursor moves to next line)
|
|
407
|
+
statusbar.display(
|
|
408
|
+
working_dir: working_dir,
|
|
409
|
+
mode: agent_config.permission_mode.to_s,
|
|
410
|
+
model: agent_config.model,
|
|
411
|
+
tasks: total_tasks,
|
|
412
|
+
cost: total_cost
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Move back down to original position (display_lines - 1 because puts moved us down 1)
|
|
416
|
+
print "\e[#{display_lines - 1}B"
|
|
417
|
+
end
|
|
391
418
|
|
|
392
|
-
# EnhancedPrompt returns
|
|
393
|
-
#
|
|
394
|
-
|
|
419
|
+
# EnhancedPrompt returns:
|
|
420
|
+
# - { text: String, images: Array } for normal input
|
|
421
|
+
# - { command: Symbol } for commands
|
|
422
|
+
# - nil on EOF
|
|
423
|
+
if result.nil?
|
|
424
|
+
current_message = nil
|
|
425
|
+
current_images = []
|
|
426
|
+
break
|
|
427
|
+
elsif result[:command]
|
|
428
|
+
# Handle commands
|
|
429
|
+
case result[:command]
|
|
430
|
+
when :clear
|
|
431
|
+
# Clear session by creating a new agent
|
|
432
|
+
agent = Clacky::Agent.new(client, agent_config, working_dir: working_dir)
|
|
433
|
+
@current_agent = agent
|
|
434
|
+
total_tasks = 0
|
|
435
|
+
total_cost = 0.0
|
|
436
|
+
ui_formatter.info("Session cleared. Starting fresh.")
|
|
437
|
+
current_message = nil
|
|
438
|
+
current_images = []
|
|
439
|
+
next
|
|
440
|
+
when :exit
|
|
441
|
+
current_message = nil
|
|
442
|
+
current_images = []
|
|
443
|
+
break
|
|
444
|
+
end
|
|
445
|
+
else
|
|
446
|
+
# Normal input with text and optional images
|
|
447
|
+
current_message = result[:text]
|
|
448
|
+
current_images = result[:images] || []
|
|
449
|
+
end
|
|
395
450
|
|
|
396
451
|
break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
|
|
397
|
-
next if current_message.strip.empty?
|
|
452
|
+
next if current_message.strip.empty? && current_images.empty?
|
|
398
453
|
|
|
399
454
|
# Display user's message after input
|
|
400
455
|
ui_formatter.user_message(current_message)
|
|
456
|
+
|
|
457
|
+
# Display image info if images were pasted (without extra newline)
|
|
458
|
+
if current_images.any?
|
|
459
|
+
current_images.each_with_index do |img_path, idx|
|
|
460
|
+
filename = File.basename(img_path)
|
|
461
|
+
say " 📎 Image #{idx + 1}: #{filename}", :cyan
|
|
462
|
+
end
|
|
463
|
+
puts # Add newline after all images
|
|
464
|
+
else
|
|
465
|
+
puts # Add newline after user message if no images
|
|
466
|
+
end
|
|
401
467
|
end
|
|
402
468
|
|
|
403
469
|
total_tasks += 1
|
|
404
470
|
|
|
405
471
|
begin
|
|
406
|
-
result = agent.run(current_message) do |event|
|
|
472
|
+
result = agent.run(current_message, images: current_images) do |event|
|
|
407
473
|
display_agent_event(event)
|
|
408
474
|
end
|
|
409
475
|
|
|
@@ -449,12 +515,14 @@ module Clacky
|
|
|
449
515
|
say "\nOr you can continue with a new task or type 'exit' to quit.", :yellow
|
|
450
516
|
end
|
|
451
517
|
|
|
452
|
-
# Clear current_message to prompt for next input
|
|
518
|
+
# Clear current_message and current_images to prompt for next input
|
|
453
519
|
current_message = nil
|
|
520
|
+
current_images = []
|
|
454
521
|
end
|
|
455
522
|
|
|
456
|
-
# Save final session state
|
|
457
|
-
|
|
523
|
+
# Save final session state only if there were actual tasks
|
|
524
|
+
# Don't save empty sessions where user just started and exited
|
|
525
|
+
if session_manager && total_tasks > 0
|
|
458
526
|
session_manager.save(agent.to_session_data)
|
|
459
527
|
end
|
|
460
528
|
|
|
@@ -25,29 +25,37 @@ module Clacky
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
def execute(path:, max_lines: 1000)
|
|
28
|
-
|
|
28
|
+
# Expand ~ to home directory
|
|
29
|
+
expanded_path = File.expand_path(path)
|
|
30
|
+
|
|
31
|
+
unless File.exist?(expanded_path)
|
|
29
32
|
return {
|
|
30
|
-
path:
|
|
33
|
+
path: expanded_path,
|
|
31
34
|
content: nil,
|
|
32
|
-
error: "File not found: #{
|
|
35
|
+
error: "File not found: #{expanded_path}"
|
|
33
36
|
}
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
# If path is a directory, list its first-level contents (similar to filetree)
|
|
40
|
+
if File.directory?(expanded_path)
|
|
41
|
+
return list_directory_contents(expanded_path)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless File.file?(expanded_path)
|
|
37
45
|
return {
|
|
38
|
-
path:
|
|
46
|
+
path: expanded_path,
|
|
39
47
|
content: nil,
|
|
40
|
-
error: "Path is not a file: #{
|
|
48
|
+
error: "Path is not a file: #{expanded_path}"
|
|
41
49
|
}
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
begin
|
|
45
|
-
lines = File.readlines(
|
|
53
|
+
lines = File.readlines(expanded_path).first(max_lines)
|
|
46
54
|
content = lines.join
|
|
47
|
-
truncated = File.readlines(
|
|
55
|
+
truncated = File.readlines(expanded_path).size > max_lines
|
|
48
56
|
|
|
49
57
|
{
|
|
50
|
-
path:
|
|
58
|
+
path: expanded_path,
|
|
51
59
|
content: content,
|
|
52
60
|
lines_read: lines.size,
|
|
53
61
|
truncated: truncated,
|
|
@@ -55,7 +63,7 @@ module Clacky
|
|
|
55
63
|
}
|
|
56
64
|
rescue StandardError => e
|
|
57
65
|
{
|
|
58
|
-
path:
|
|
66
|
+
path: expanded_path,
|
|
59
67
|
content: nil,
|
|
60
68
|
error: "Error reading file: #{e.message}"
|
|
61
69
|
}
|
|
@@ -70,10 +78,65 @@ module Clacky
|
|
|
70
78
|
def format_result(result)
|
|
71
79
|
return result[:error] if result[:error]
|
|
72
80
|
|
|
81
|
+
# Handle directory listing
|
|
82
|
+
if result[:is_directory] || result['is_directory']
|
|
83
|
+
entries = result[:entries_count] || result['entries_count'] || 0
|
|
84
|
+
dirs = result[:directories_count] || result['directories_count'] || 0
|
|
85
|
+
files = result[:files_count] || result['files_count'] || 0
|
|
86
|
+
return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Handle file reading
|
|
73
90
|
lines = result[:lines_read] || result['lines_read'] || 0
|
|
74
91
|
truncated = result[:truncated] || result['truncated']
|
|
75
92
|
"Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
|
|
76
93
|
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# List first-level directory contents (files and directories)
|
|
98
|
+
def list_directory_contents(path)
|
|
99
|
+
begin
|
|
100
|
+
entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
|
|
101
|
+
|
|
102
|
+
# Separate files and directories
|
|
103
|
+
files = []
|
|
104
|
+
directories = []
|
|
105
|
+
|
|
106
|
+
entries.each do |entry|
|
|
107
|
+
full_path = File.join(path, entry)
|
|
108
|
+
if File.directory?(full_path)
|
|
109
|
+
directories << entry + "/"
|
|
110
|
+
else
|
|
111
|
+
files << entry
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Sort directories and files separately, then combine
|
|
116
|
+
directories.sort!
|
|
117
|
+
files.sort!
|
|
118
|
+
all_entries = directories + files
|
|
119
|
+
|
|
120
|
+
# Format as a tree-like structure
|
|
121
|
+
content = all_entries.map { |entry| " #{entry}" }.join("\n")
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
path: path,
|
|
125
|
+
content: "Directory listing:\n#{content}",
|
|
126
|
+
entries_count: all_entries.size,
|
|
127
|
+
directories_count: directories.size,
|
|
128
|
+
files_count: files.size,
|
|
129
|
+
is_directory: true,
|
|
130
|
+
error: nil
|
|
131
|
+
}
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
{
|
|
134
|
+
path: path,
|
|
135
|
+
content: nil,
|
|
136
|
+
error: "Error reading directory: #{e.message}"
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
77
140
|
end
|
|
78
141
|
end
|
|
79
142
|
end
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -256,6 +256,50 @@ module Clacky
|
|
|
256
256
|
end
|
|
257
257
|
end
|
|
258
258
|
|
|
259
|
+
# Format result for LLM consumption - return a compact version to save tokens
|
|
260
|
+
def format_result_for_llm(result)
|
|
261
|
+
# If there's an error, return it as-is
|
|
262
|
+
return result if result[:error]
|
|
263
|
+
|
|
264
|
+
# Build a compact summary with file list and sample matches
|
|
265
|
+
compact = {
|
|
266
|
+
summary: {
|
|
267
|
+
total_matches: result[:total_matches],
|
|
268
|
+
files_with_matches: result[:files_with_matches],
|
|
269
|
+
files_searched: result[:files_searched],
|
|
270
|
+
truncated: result[:truncated],
|
|
271
|
+
truncation_reason: result[:truncation_reason]
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Include list of files with match counts
|
|
276
|
+
if result[:results] && !result[:results].empty?
|
|
277
|
+
compact[:files] = result[:results].map do |file_result|
|
|
278
|
+
{
|
|
279
|
+
file: file_result[:file],
|
|
280
|
+
match_count: file_result[:matches].length
|
|
281
|
+
}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Include sample matches (first 2 matches from first 3 files) for context
|
|
285
|
+
sample_results = result[:results].take(3)
|
|
286
|
+
compact[:sample_matches] = sample_results.map do |file_result|
|
|
287
|
+
{
|
|
288
|
+
file: file_result[:file],
|
|
289
|
+
matches: file_result[:matches].take(2).map do |match|
|
|
290
|
+
{
|
|
291
|
+
line_number: match[:line_number],
|
|
292
|
+
line: match[:line]
|
|
293
|
+
# Omit context to save space - it's rarely needed by LLM
|
|
294
|
+
}
|
|
295
|
+
end
|
|
296
|
+
}
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
compact
|
|
301
|
+
end
|
|
302
|
+
|
|
259
303
|
private
|
|
260
304
|
|
|
261
305
|
# Find .gitignore file in the search path or parent directories
|
|
@@ -9,7 +9,7 @@ require "base64"
|
|
|
9
9
|
module Clacky
|
|
10
10
|
module UI
|
|
11
11
|
# Enhanced input prompt with multi-line support and image paste
|
|
12
|
-
#
|
|
12
|
+
#
|
|
13
13
|
# Features:
|
|
14
14
|
# - Shift+Enter: Add new line
|
|
15
15
|
# - Enter: Submit message
|
|
@@ -20,6 +20,7 @@ module Clacky
|
|
|
20
20
|
|
|
21
21
|
def initialize
|
|
22
22
|
@pastel = Pastel.new
|
|
23
|
+
@formatter = Formatter.new
|
|
23
24
|
@images = [] # Array of image file paths
|
|
24
25
|
@paste_counter = 0 # Counter for paste operations
|
|
25
26
|
@paste_placeholders = {} # Map of placeholder text to actual pasted content
|
|
@@ -29,8 +30,12 @@ module Clacky
|
|
|
29
30
|
|
|
30
31
|
# Read user input with enhanced features
|
|
31
32
|
# @param prefix [String] Prompt prefix (default: "❯")
|
|
32
|
-
# @
|
|
33
|
-
|
|
33
|
+
# @param block [Proc] Optional callback when Shift+Tab is pressed (receives display_lines)
|
|
34
|
+
# @return [Hash, nil] Returns:
|
|
35
|
+
# - { text: String, images: Array } for normal input
|
|
36
|
+
# - { command: Symbol } for commands (:clear, :exit)
|
|
37
|
+
# - nil on EOF
|
|
38
|
+
def read_input(prefix: "❯", &block)
|
|
34
39
|
@images = []
|
|
35
40
|
lines = []
|
|
36
41
|
cursor_pos = 0
|
|
@@ -47,18 +52,18 @@ module Clacky
|
|
|
47
52
|
rescue Interrupt
|
|
48
53
|
return nil
|
|
49
54
|
end
|
|
50
|
-
|
|
55
|
+
|
|
51
56
|
# Handle buffered rapid input (system paste detection)
|
|
52
57
|
if key.is_a?(Hash) && key[:type] == :rapid_input
|
|
53
58
|
pasted_text = key[:text]
|
|
54
|
-
pasted_lines = pasted_text.split(
|
|
55
|
-
|
|
59
|
+
pasted_lines = pasted_text.split(/\r\n|\r|\n/)
|
|
60
|
+
|
|
56
61
|
if pasted_lines.size > 1
|
|
57
62
|
# Multi-line rapid input - use placeholder for display
|
|
58
63
|
@paste_counter += 1
|
|
59
64
|
placeholder = "[##{@paste_counter} Paste Text]"
|
|
60
65
|
@paste_placeholders[placeholder] = pasted_text
|
|
61
|
-
|
|
66
|
+
|
|
62
67
|
# Insert placeholder at cursor position
|
|
63
68
|
chars = (lines[line_index] || "").chars
|
|
64
69
|
placeholder_chars = placeholder.chars
|
|
@@ -91,8 +96,33 @@ module Clacky
|
|
|
91
96
|
cursor_pos = 0
|
|
92
97
|
|
|
93
98
|
when "\r" # Enter - submit
|
|
99
|
+
# Check if it's a command
|
|
100
|
+
input_text = lines.join("\n").strip
|
|
101
|
+
|
|
102
|
+
if input_text.start_with?('/')
|
|
103
|
+
clear_simple_prompt(lines.size)
|
|
104
|
+
|
|
105
|
+
# Parse command
|
|
106
|
+
case input_text
|
|
107
|
+
when '/clear'
|
|
108
|
+
@last_display_lines = 0 # Reset so CLI messages won't be cleared
|
|
109
|
+
return { command: :clear }
|
|
110
|
+
when '/exit', '/quit'
|
|
111
|
+
@last_display_lines = 0 # Reset before exit
|
|
112
|
+
return { command: :exit }
|
|
113
|
+
else
|
|
114
|
+
# Unknown command - show error and continue
|
|
115
|
+
@formatter.warning("Unknown command: #{input_text} (Available: /clear, /exit)")
|
|
116
|
+
@last_display_lines = 0 # Reset so next display won't clear these messages
|
|
117
|
+
lines = []
|
|
118
|
+
cursor_pos = 0
|
|
119
|
+
line_index = 0
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
94
124
|
# Submit if not empty
|
|
95
|
-
unless
|
|
125
|
+
unless input_text.empty? && @images.empty?
|
|
96
126
|
clear_simple_prompt(lines.size)
|
|
97
127
|
# Replace placeholders with actual pasted content
|
|
98
128
|
final_text = expand_placeholders(lines.join("\n"))
|
|
@@ -102,12 +132,12 @@ module Clacky
|
|
|
102
132
|
when "\u0003" # Ctrl+C
|
|
103
133
|
# Check if input is empty
|
|
104
134
|
has_content = lines.any? { |line| !line.strip.empty? } || @images.any?
|
|
105
|
-
|
|
135
|
+
|
|
106
136
|
if has_content
|
|
107
137
|
# Input has content - clear it on first Ctrl+C
|
|
108
138
|
current_time = Time.now.to_f
|
|
109
139
|
time_since_last = @last_ctrl_c_time ? (current_time - @last_ctrl_c_time) : Float::INFINITY
|
|
110
|
-
|
|
140
|
+
|
|
111
141
|
if time_since_last < 2.0 # Within 2 seconds of last Ctrl+C
|
|
112
142
|
# Second Ctrl+C within 2 seconds - exit
|
|
113
143
|
clear_simple_prompt(lines.size)
|
|
@@ -131,19 +161,51 @@ module Clacky
|
|
|
131
161
|
when "\u0016" # Ctrl+V - Paste
|
|
132
162
|
pasted = paste_from_clipboard
|
|
133
163
|
if pasted[:type] == :image
|
|
134
|
-
# Save image and add to list
|
|
135
|
-
@images
|
|
164
|
+
# Save image and add to list (max 3 images)
|
|
165
|
+
if @images.size < 3
|
|
166
|
+
@images << pasted[:path]
|
|
167
|
+
else
|
|
168
|
+
# Show warning below input box (without extra newline)
|
|
169
|
+
@formatter.warning("Maximum 3 images allowed. Delete an image first (Ctrl+D).")
|
|
170
|
+
|
|
171
|
+
# Wait a moment for user to see the message
|
|
172
|
+
sleep(1.5)
|
|
173
|
+
|
|
174
|
+
# Clear the warning line
|
|
175
|
+
print "\r\e[2K" # Clear current line
|
|
176
|
+
|
|
177
|
+
# Now clear the entire input box using the saved line count
|
|
178
|
+
if @last_display_lines && @last_display_lines > 0
|
|
179
|
+
# Move up to the first line of input box
|
|
180
|
+
(@last_display_lines - 1).times do
|
|
181
|
+
print "\e[1A"
|
|
182
|
+
end
|
|
183
|
+
# Clear all lines
|
|
184
|
+
@last_display_lines.times do |i|
|
|
185
|
+
print "\r\e[2K"
|
|
186
|
+
print "\e[1B" if i < @last_display_lines - 1
|
|
187
|
+
end
|
|
188
|
+
# Move back to the first line
|
|
189
|
+
(@last_display_lines - 1).times do
|
|
190
|
+
print "\e[1A"
|
|
191
|
+
end
|
|
192
|
+
print "\r"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Reset display state so next display will redraw
|
|
196
|
+
@last_display_lines = 0
|
|
197
|
+
end
|
|
136
198
|
else
|
|
137
199
|
# Handle pasted text
|
|
138
200
|
pasted_text = pasted[:text]
|
|
139
|
-
pasted_lines = pasted_text.split(
|
|
140
|
-
|
|
201
|
+
pasted_lines = pasted_text.split(/\r\n|\r|\n/)
|
|
202
|
+
|
|
141
203
|
if pasted_lines.size > 1
|
|
142
204
|
# Multi-line paste - use placeholder for display
|
|
143
205
|
@paste_counter += 1
|
|
144
206
|
placeholder = "[##{@paste_counter} Paste Text]"
|
|
145
207
|
@paste_placeholders[placeholder] = pasted_text
|
|
146
|
-
|
|
208
|
+
|
|
147
209
|
# Insert placeholder at cursor position
|
|
148
210
|
chars = (lines[line_index] || "").chars
|
|
149
211
|
placeholder_chars = placeholder.chars
|
|
@@ -196,12 +258,140 @@ module Clacky
|
|
|
196
258
|
when "\e[D" # Left arrow
|
|
197
259
|
cursor_pos = [cursor_pos - 1, 0].max
|
|
198
260
|
|
|
261
|
+
# Ignore Shift+Arrow keys (they produce sequences like \e[1;2A, \e[1;2B, etc.)
|
|
262
|
+
when /\e\[1;2[ABCD]/
|
|
263
|
+
# Do nothing - ignore Shift+Arrow keys
|
|
264
|
+
|
|
265
|
+
when "\e[Z" # Shift+Tab - Toggle auto-approve mode
|
|
266
|
+
# Call the block to update status bar if provided
|
|
267
|
+
if block
|
|
268
|
+
block.call(@last_display_lines)
|
|
269
|
+
end
|
|
270
|
+
# Continue the input loop, don't return
|
|
271
|
+
|
|
272
|
+
when "\u0001" # Ctrl+A - Move to beginning of line
|
|
273
|
+
cursor_pos = 0
|
|
274
|
+
|
|
275
|
+
when "\u0005" # Ctrl+E - Move to end of line
|
|
276
|
+
current_line = lines[line_index] || ""
|
|
277
|
+
cursor_pos = current_line.chars.length
|
|
278
|
+
|
|
279
|
+
when "\u0006" # Ctrl+F - Move forward one character
|
|
280
|
+
current_line = lines[line_index] || ""
|
|
281
|
+
cursor_pos = [cursor_pos + 1, current_line.chars.length].min
|
|
282
|
+
|
|
283
|
+
when "\u0002" # Ctrl+B - Move backward one character
|
|
284
|
+
cursor_pos = [cursor_pos - 1, 0].max
|
|
285
|
+
|
|
286
|
+
when "\u000B" # Ctrl+K - Delete from cursor to end of line
|
|
287
|
+
current_line = lines[line_index] || ""
|
|
288
|
+
chars = current_line.chars
|
|
289
|
+
lines[line_index] = chars[0...cursor_pos].join
|
|
290
|
+
|
|
291
|
+
when "\u0015" # Ctrl+U - Delete from beginning of line to cursor
|
|
292
|
+
current_line = lines[line_index] || ""
|
|
293
|
+
chars = current_line.chars
|
|
294
|
+
lines[line_index] = chars[cursor_pos..-1].join || ""
|
|
295
|
+
cursor_pos = 0
|
|
296
|
+
|
|
297
|
+
when "\u0017" # Ctrl+W - Delete previous word
|
|
298
|
+
current_line = lines[line_index] || ""
|
|
299
|
+
chars = current_line.chars
|
|
300
|
+
|
|
301
|
+
# Find the start of the previous word
|
|
302
|
+
pos = cursor_pos - 1
|
|
303
|
+
|
|
304
|
+
# Skip trailing whitespace
|
|
305
|
+
while pos >= 0 && chars[pos] =~ /\s/
|
|
306
|
+
pos -= 1
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Delete word characters
|
|
310
|
+
while pos >= 0 && chars[pos] =~ /\S/
|
|
311
|
+
pos -= 1
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Delete from pos+1 to cursor_pos
|
|
315
|
+
delete_start = pos + 1
|
|
316
|
+
chars.slice!(delete_start...cursor_pos)
|
|
317
|
+
lines[line_index] = chars.join
|
|
318
|
+
cursor_pos = delete_start
|
|
319
|
+
|
|
199
320
|
when "\u0004" # Ctrl+D - Delete image by number
|
|
200
321
|
if @images.any?
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
322
|
+
# If only one image, delete it directly
|
|
323
|
+
if @images.size == 1
|
|
324
|
+
@images.clear
|
|
325
|
+
|
|
326
|
+
# Clear the entire input box
|
|
327
|
+
if @last_display_lines && @last_display_lines > 0
|
|
328
|
+
# Move up to the first line of input box
|
|
329
|
+
(@last_display_lines - 1).times do
|
|
330
|
+
print "\e[1A"
|
|
331
|
+
end
|
|
332
|
+
# Clear all lines
|
|
333
|
+
@last_display_lines.times do |i|
|
|
334
|
+
print "\r\e[2K"
|
|
335
|
+
print "\e[1B" if i < @last_display_lines - 1
|
|
336
|
+
end
|
|
337
|
+
# Move back to the first line
|
|
338
|
+
(@last_display_lines - 1).times do
|
|
339
|
+
print "\e[1A"
|
|
340
|
+
end
|
|
341
|
+
print "\r"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Reset so next display starts fresh
|
|
345
|
+
@last_display_lines = 0
|
|
346
|
+
else
|
|
347
|
+
# Multiple images - ask which one to delete
|
|
348
|
+
# Move cursor to after the input box to show prompt
|
|
349
|
+
print "\n"
|
|
350
|
+
print "Delete image (1-#{@images.size}): "
|
|
351
|
+
$stdout.flush
|
|
352
|
+
|
|
353
|
+
# Read single character without waiting for Enter
|
|
354
|
+
deleted = false
|
|
355
|
+
$stdin.raw do |io|
|
|
356
|
+
char = io.getc
|
|
357
|
+
num = char.to_i
|
|
358
|
+
|
|
359
|
+
# Delete if valid number
|
|
360
|
+
if num > 0 && num <= @images.size
|
|
361
|
+
@images.delete_at(num - 1)
|
|
362
|
+
print "#{num} ✓"
|
|
363
|
+
deleted = true
|
|
364
|
+
else
|
|
365
|
+
print "✗"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Clear the prompt lines
|
|
370
|
+
print "\r\e[2K" # Clear current line
|
|
371
|
+
print "\e[1A" # Move up one line
|
|
372
|
+
print "\r\e[2K" # Clear the prompt line
|
|
373
|
+
|
|
374
|
+
# Now clear the entire input box using the saved line count
|
|
375
|
+
if @last_display_lines && @last_display_lines > 0
|
|
376
|
+
# We're now at the position where the input box ends
|
|
377
|
+
# Move up to the first line of input box
|
|
378
|
+
(@last_display_lines - 1).times do
|
|
379
|
+
print "\e[1A"
|
|
380
|
+
end
|
|
381
|
+
# Clear all lines
|
|
382
|
+
@last_display_lines.times do |i|
|
|
383
|
+
print "\r\e[2K"
|
|
384
|
+
print "\e[1B" if i < @last_display_lines - 1
|
|
385
|
+
end
|
|
386
|
+
# Move back to the first line
|
|
387
|
+
(@last_display_lines - 1).times do
|
|
388
|
+
print "\e[1A"
|
|
389
|
+
end
|
|
390
|
+
print "\r"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Reset so next display starts fresh
|
|
394
|
+
@last_display_lines = 0
|
|
205
395
|
end
|
|
206
396
|
end
|
|
207
397
|
|
|
@@ -210,7 +400,7 @@ module Clacky
|
|
|
210
400
|
if key.length >= 1 && key != "\e" && !key.start_with?("\e") && key.ord >= 32
|
|
211
401
|
lines[line_index] ||= ""
|
|
212
402
|
current_line = lines[line_index]
|
|
213
|
-
|
|
403
|
+
|
|
214
404
|
# Insert character at cursor position (using character index, not byte index)
|
|
215
405
|
chars = current_line.chars
|
|
216
406
|
chars.insert(cursor_pos, key)
|
|
@@ -228,17 +418,11 @@ module Clacky
|
|
|
228
418
|
|
|
229
419
|
# Display simplified prompt (just prefix and input, no box)
|
|
230
420
|
def display_simple_prompt(lines, prefix, line_index, cursor_pos)
|
|
231
|
-
#
|
|
232
|
-
|
|
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
|
|
421
|
+
# Hide terminal cursor (we render our own)
|
|
422
|
+
print "\e[?25l"
|
|
239
423
|
|
|
240
424
|
lines_to_display = []
|
|
241
|
-
|
|
425
|
+
|
|
242
426
|
# Get terminal width for full-width separator
|
|
243
427
|
term_width = TTY::Screen.width
|
|
244
428
|
|
|
@@ -250,14 +434,14 @@ module Clacky
|
|
|
250
434
|
@images.each_with_index do |img_path, idx|
|
|
251
435
|
filename = File.basename(img_path)
|
|
252
436
|
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
253
|
-
|
|
437
|
+
line = @pastel.dim("[Image #{idx + 1}] #{filename} (#{filesize}) (Ctrl+D to delete)")
|
|
438
|
+
lines_to_display << line
|
|
254
439
|
end
|
|
255
|
-
lines_to_display << ""
|
|
256
440
|
end
|
|
257
441
|
|
|
258
442
|
# Display input lines
|
|
259
443
|
display_lines = lines.empty? ? [""] : lines
|
|
260
|
-
|
|
444
|
+
|
|
261
445
|
display_lines.each_with_index do |line, idx|
|
|
262
446
|
if idx == 0
|
|
263
447
|
# First line with prefix
|
|
@@ -267,7 +451,7 @@ module Clacky
|
|
|
267
451
|
before_cursor = chars[0...cursor_pos].join
|
|
268
452
|
cursor_char = chars[cursor_pos] || " "
|
|
269
453
|
after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
|
|
270
|
-
|
|
454
|
+
|
|
271
455
|
line_display = "#{prefix} #{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
|
|
272
456
|
lines_to_display << line_display
|
|
273
457
|
else
|
|
@@ -282,7 +466,7 @@ module Clacky
|
|
|
282
466
|
before_cursor = chars[0...cursor_pos].join
|
|
283
467
|
cursor_char = chars[cursor_pos] || " "
|
|
284
468
|
after_cursor = chars[(cursor_pos + 1)..-1]&.join || ""
|
|
285
|
-
|
|
469
|
+
|
|
286
470
|
line_display = "#{indent}#{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
|
|
287
471
|
lines_to_display << line_display
|
|
288
472
|
else
|
|
@@ -290,27 +474,72 @@ module Clacky
|
|
|
290
474
|
end
|
|
291
475
|
end
|
|
292
476
|
end
|
|
293
|
-
|
|
477
|
+
|
|
294
478
|
# Bottom separator line (full width)
|
|
295
479
|
lines_to_display << @pastel.dim("─" * term_width)
|
|
296
480
|
|
|
297
|
-
#
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
481
|
+
# Different rendering strategy for first display vs updates
|
|
482
|
+
if @last_display_lines && @last_display_lines > 0
|
|
483
|
+
# Update mode: move to start and overwrite (no flicker)
|
|
484
|
+
# Move up to the first line (N-1 times since we're on line N)
|
|
485
|
+
(@last_display_lines - 1).times do
|
|
486
|
+
print "\e[1A" # Move up one line
|
|
487
|
+
end
|
|
488
|
+
print "\r" # Move to beginning of line
|
|
489
|
+
|
|
490
|
+
# Output lines by overwriting
|
|
491
|
+
lines_to_display.each_with_index do |line, idx|
|
|
492
|
+
print "\r\e[K" # Clear current line from cursor to end
|
|
493
|
+
print line
|
|
494
|
+
print "\n" if idx < lines_to_display.size - 1 # Newline except last line
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# If new display has fewer lines than old, clear the extra lines
|
|
498
|
+
if lines_to_display.size < @last_display_lines - 1
|
|
499
|
+
extra_lines = @last_display_lines - 1 - lines_to_display.size
|
|
500
|
+
extra_lines.times do
|
|
501
|
+
print "\n\r\e[K" # Move down and clear line
|
|
502
|
+
end
|
|
503
|
+
# Move back up to the last line of new display
|
|
504
|
+
extra_lines.times do
|
|
505
|
+
print "\e[1A"
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
print "\n" # Move cursor to next line
|
|
510
|
+
else
|
|
511
|
+
# First display: use simple newline approach
|
|
512
|
+
print lines_to_display.join("\n")
|
|
513
|
+
print "\n"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Flush output to ensure it's displayed immediately
|
|
517
|
+
$stdout.flush
|
|
518
|
+
|
|
519
|
+
# Remember how many lines we displayed (including the newline)
|
|
520
|
+
@last_display_lines = lines_to_display.size + 1
|
|
303
521
|
end
|
|
304
522
|
|
|
305
523
|
# Clear simple prompt display
|
|
306
524
|
def clear_simple_prompt(num_lines)
|
|
307
525
|
if @last_display_lines && @last_display_lines > 0
|
|
308
|
-
|
|
526
|
+
# Move up to the first line (N-1 times since we're on line N)
|
|
527
|
+
(@last_display_lines - 1).times do
|
|
309
528
|
print "\e[1A" # Move up one line
|
|
310
|
-
|
|
529
|
+
end
|
|
530
|
+
# Now we're on the first line, clear all N lines
|
|
531
|
+
@last_display_lines.times do |i|
|
|
532
|
+
print "\r\e[2K" # Move to beginning and clear entire line
|
|
533
|
+
print "\e[1B" if i < @last_display_lines - 1 # Move down (except last line)
|
|
534
|
+
end
|
|
535
|
+
# Move back to the first line
|
|
536
|
+
(@last_display_lines - 1).times do
|
|
537
|
+
print "\e[1A"
|
|
311
538
|
end
|
|
312
539
|
print "\r" # Move to beginning of line
|
|
313
540
|
end
|
|
541
|
+
# Show terminal cursor again
|
|
542
|
+
print "\e[?25h"
|
|
314
543
|
end
|
|
315
544
|
|
|
316
545
|
# Expand placeholders to actual pasted content
|
|
@@ -327,74 +556,91 @@ module Clacky
|
|
|
327
556
|
# Also detects rapid input (paste-like behavior)
|
|
328
557
|
def read_key_with_rapid_detection
|
|
329
558
|
$stdin.set_encoding('UTF-8')
|
|
330
|
-
|
|
559
|
+
|
|
331
560
|
current_time = Time.now.to_f
|
|
332
561
|
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
333
562
|
@last_input_time = current_time
|
|
334
|
-
|
|
563
|
+
|
|
335
564
|
$stdin.raw do |io|
|
|
336
565
|
io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
|
|
337
566
|
c = io.getc
|
|
338
|
-
|
|
567
|
+
|
|
339
568
|
# Ensure character is UTF-8 encoded
|
|
340
569
|
c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
|
|
341
|
-
|
|
570
|
+
|
|
342
571
|
# Handle escape sequences (arrow keys, special keys)
|
|
343
572
|
if c == "\e"
|
|
344
|
-
# Read the next
|
|
573
|
+
# Read the next character to determine sequence type
|
|
345
574
|
begin
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
c = c +
|
|
575
|
+
next_char = io.read_nonblock(1)
|
|
576
|
+
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
577
|
+
c = c + next_char
|
|
578
|
+
|
|
579
|
+
# If it's a CSI sequence (starts with [)
|
|
580
|
+
if next_char == "["
|
|
581
|
+
# Read until we get a letter (the final character of CSI sequence)
|
|
582
|
+
# This handles both simple sequences like \e[A and complex ones like \e[1;2D
|
|
583
|
+
loop do
|
|
584
|
+
if IO.select([io], nil, nil, 0.01) # 10ms timeout
|
|
585
|
+
char = io.read_nonblock(1)
|
|
586
|
+
char = char.force_encoding('UTF-8') if char.encoding != Encoding::UTF_8
|
|
587
|
+
c = c + char
|
|
588
|
+
# Break if we got a letter (final character of CSI sequence)
|
|
589
|
+
break if char =~ /[A-Za-z~]/
|
|
590
|
+
else
|
|
591
|
+
break
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
end
|
|
349
595
|
rescue IO::WaitReadable, Errno::EAGAIN
|
|
350
596
|
# No more characters available
|
|
351
597
|
end
|
|
352
598
|
return c
|
|
353
599
|
end
|
|
354
|
-
|
|
600
|
+
|
|
355
601
|
# Check if there are more characters available using IO.select with timeout 0
|
|
356
602
|
has_more_input = IO.select([io], nil, nil, 0)
|
|
357
|
-
|
|
603
|
+
|
|
358
604
|
# If this is rapid input or there are more characters available
|
|
359
605
|
if is_rapid_input || has_more_input
|
|
360
606
|
# Buffer rapid input
|
|
361
607
|
buffer = c.to_s.dup
|
|
362
608
|
buffer.force_encoding('UTF-8')
|
|
363
|
-
|
|
609
|
+
|
|
364
610
|
# Keep reading available characters
|
|
365
611
|
loop do
|
|
366
612
|
begin
|
|
367
613
|
next_char = io.read_nonblock(1)
|
|
368
614
|
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
369
615
|
buffer << next_char
|
|
370
|
-
|
|
616
|
+
|
|
371
617
|
# Continue only if more characters are immediately available
|
|
372
618
|
break unless IO.select([io], nil, nil, 0)
|
|
373
619
|
rescue IO::WaitReadable, Errno::EAGAIN
|
|
374
620
|
break
|
|
375
621
|
end
|
|
376
622
|
end
|
|
377
|
-
|
|
623
|
+
|
|
378
624
|
# Ensure buffer is UTF-8
|
|
379
625
|
buffer.force_encoding('UTF-8')
|
|
380
|
-
|
|
626
|
+
|
|
381
627
|
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
382
628
|
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
383
629
|
# Remove any trailing \r or \n from rapid input buffer
|
|
384
630
|
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
385
631
|
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
386
632
|
end
|
|
387
|
-
|
|
633
|
+
|
|
388
634
|
# Single character rapid input, return as-is
|
|
389
635
|
return buffer[0] if buffer.length == 1
|
|
390
636
|
end
|
|
391
|
-
|
|
637
|
+
|
|
392
638
|
c
|
|
393
639
|
end
|
|
394
640
|
rescue Errno::EINTR
|
|
395
641
|
"\u0003" # Treat interrupt as Ctrl+C
|
|
396
642
|
end
|
|
397
|
-
|
|
643
|
+
|
|
398
644
|
# Legacy method for compatibility
|
|
399
645
|
def read_key
|
|
400
646
|
read_key_with_rapid_detection
|
|
@@ -419,17 +665,17 @@ module Clacky
|
|
|
419
665
|
def paste_from_clipboard_macos
|
|
420
666
|
require 'shellwords'
|
|
421
667
|
require 'fileutils'
|
|
422
|
-
|
|
668
|
+
|
|
423
669
|
# First check if there's an image in clipboard
|
|
424
670
|
# Use osascript to check clipboard content type
|
|
425
671
|
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
|
-
|
|
672
|
+
|
|
427
673
|
if has_image
|
|
428
674
|
# Create a persistent temporary file (won't be auto-deleted)
|
|
429
675
|
temp_dir = Dir.tmpdir
|
|
430
676
|
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
431
677
|
temp_path = File.join(temp_dir, temp_filename)
|
|
432
|
-
|
|
678
|
+
|
|
433
679
|
# Extract image using osascript
|
|
434
680
|
script = <<~APPLESCRIPT
|
|
435
681
|
set png_data to the clipboard as «class PNGf»
|
|
@@ -437,9 +683,9 @@ module Clacky
|
|
|
437
683
|
write png_data to the_file
|
|
438
684
|
close access the_file
|
|
439
685
|
APPLESCRIPT
|
|
440
|
-
|
|
686
|
+
|
|
441
687
|
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
442
|
-
|
|
688
|
+
|
|
443
689
|
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
444
690
|
return { type: :image, path: temp_path }
|
|
445
691
|
end
|
|
@@ -459,13 +705,13 @@ module Clacky
|
|
|
459
705
|
# Paste from Linux clipboard
|
|
460
706
|
def paste_from_clipboard_linux
|
|
461
707
|
require 'shellwords'
|
|
462
|
-
|
|
708
|
+
|
|
463
709
|
# Check if xclip is available
|
|
464
710
|
if system("which xclip >/dev/null 2>&1")
|
|
465
711
|
# Try to get image first
|
|
466
712
|
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
467
713
|
temp_file.close
|
|
468
|
-
|
|
714
|
+
|
|
469
715
|
# Try different image MIME types
|
|
470
716
|
["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
|
|
471
717
|
if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
|
|
@@ -474,7 +720,7 @@ module Clacky
|
|
|
474
720
|
end
|
|
475
721
|
end
|
|
476
722
|
end
|
|
477
|
-
|
|
723
|
+
|
|
478
724
|
# No image, get text - ensure UTF-8 encoding
|
|
479
725
|
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
480
726
|
text.force_encoding('UTF-8')
|
|
@@ -498,7 +744,7 @@ module Clacky
|
|
|
498
744
|
# Try to get image using PowerShell
|
|
499
745
|
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
500
746
|
temp_file.close
|
|
501
|
-
|
|
747
|
+
|
|
502
748
|
ps_script = <<~POWERSHELL
|
|
503
749
|
Add-Type -AssemblyName System.Windows.Forms
|
|
504
750
|
$img = [Windows.Forms.Clipboard]::GetImage()
|
|
@@ -509,9 +755,9 @@ module Clacky
|
|
|
509
755
|
exit 1
|
|
510
756
|
}
|
|
511
757
|
POWERSHELL
|
|
512
|
-
|
|
758
|
+
|
|
513
759
|
success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
|
|
514
|
-
|
|
760
|
+
|
|
515
761
|
if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
|
|
516
762
|
return { type: :image, path: temp_file.path }
|
|
517
763
|
end
|
data/lib/clacky/ui/formatter.rb
CHANGED
data/lib/clacky/version.rb
CHANGED