openclacky 0.5.3 → 0.5.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 +13 -0
- data/README.md +2 -0
- data/Rakefile +1 -5
- data/lib/clacky/cli.rb +7 -3
- data/lib/clacky/gitignore_parser.rb +114 -0
- data/lib/clacky/tools/grep.rb +245 -30
- data/lib/clacky/ui/enhanced_prompt.rb +643 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -1
- metadata +3 -2
- data/lib/clacky/ui/prompt.rb +0 -72
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f86dc057b8fd69db07c40a5633ffe1350cf0ddb8509b6e26705464d9e127daa4
|
|
4
|
+
data.tar.gz: 58d3e6b89e129d6c7ef7dbc57ff57e95ee8d6acd96186491daa8670b6031c3b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d724c3d9404faed0bead1fb3e417782f15a9d89dde03709696120df87230d7a6d33250684e96e99f13b87f3993bfffd53dc5b52c87c34e79844a171429685af
|
|
7
|
+
data.tar.gz: 837b07953b38515d0c1bf0b6081a1fd10e7fffa46f9cbf1d1721744cabe494c5a018bcb1d0290e988782a534aad9ab24137978d7299d1908cb9fb248258de282
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.4] - 2026-01-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Automatic Paste Detection**: Rapid input detection automatically identifies paste operations
|
|
14
|
+
- **Word Wrap Display**: Long input lines automatically wrap with scroll indicators (up to 15 visible lines)
|
|
15
|
+
- **Full-width Terminal Display**: Enhanced prompt box uses full terminal width for better visibility
|
|
16
|
+
|
|
17
|
+
### Improved
|
|
18
|
+
- **Smart Ctrl+C Handling**: First press clears content, second press (within 2s) exits
|
|
19
|
+
- **UTF-8 Encoding**: Better handling of multi-byte characters in clipboard operations
|
|
20
|
+
- **Cursor Positioning**: Improved cursor tracking in wrapped lines
|
|
21
|
+
- **Multi-line Paste**: Better display for pasted content with placeholder support
|
|
22
|
+
|
|
10
23
|
## [0.5.0] - 2026-01-11
|
|
11
24
|
|
|
12
25
|
### Added
|
data/README.md
CHANGED
|
@@ -6,6 +6,8 @@ A command-line interface for interacting with AI models. OpenClacky supports Ope
|
|
|
6
6
|
|
|
7
7
|
- 💬 Interactive chat sessions with AI models
|
|
8
8
|
- 🤖 Autonomous AI agent with tool use capabilities
|
|
9
|
+
- 📝 Enhanced input with multi-line support and Unicode (Chinese, etc.)
|
|
10
|
+
- 🖼️ Paste images from clipboard (macOS/Linux)
|
|
9
11
|
- 🚀 Single-message mode for quick queries
|
|
10
12
|
- 🔐 Secure API key management
|
|
11
13
|
- 📝 Multi-turn conversation support
|
data/Rakefile
CHANGED
|
@@ -5,10 +5,6 @@ require "rspec/core/rake_task"
|
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
require "rubocop/rake_task"
|
|
9
|
-
|
|
10
|
-
RuboCop::RakeTask.new
|
|
11
|
-
|
|
12
8
|
namespace :build do
|
|
13
9
|
desc "Build both openclacky and clacky gems"
|
|
14
10
|
task :all do
|
|
@@ -35,4 +31,4 @@ namespace :build do
|
|
|
35
31
|
end
|
|
36
32
|
end
|
|
37
33
|
|
|
38
|
-
task default: %i[spec
|
|
34
|
+
task default: %i[spec]
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -4,7 +4,7 @@ require "thor"
|
|
|
4
4
|
require "tty-prompt"
|
|
5
5
|
require "tty-spinner"
|
|
6
6
|
require_relative "ui/banner"
|
|
7
|
-
require_relative "ui/
|
|
7
|
+
require_relative "ui/enhanced_prompt"
|
|
8
8
|
require_relative "ui/statusbar"
|
|
9
9
|
require_relative "ui/formatter"
|
|
10
10
|
|
|
@@ -409,7 +409,11 @@ module Clacky
|
|
|
409
409
|
)
|
|
410
410
|
|
|
411
411
|
# Use enhanced prompt with "You:" prefix
|
|
412
|
-
|
|
412
|
+
result = prompt.read_input(prefix: "You:")
|
|
413
|
+
|
|
414
|
+
# EnhancedPrompt returns { text: String, images: Array } or nil
|
|
415
|
+
# For now, we only use the text part
|
|
416
|
+
current_message = result.nil? ? nil : result[:text]
|
|
413
417
|
|
|
414
418
|
break if current_message.nil? || %w[exit quit].include?(current_message&.downcase&.strip)
|
|
415
419
|
next if current_message.strip.empty?
|
|
@@ -613,7 +617,7 @@ module Clacky
|
|
|
613
617
|
end
|
|
614
618
|
|
|
615
619
|
def ui_prompt
|
|
616
|
-
@ui_prompt ||= UI::
|
|
620
|
+
@ui_prompt ||= UI::EnhancedPrompt.new
|
|
617
621
|
end
|
|
618
622
|
|
|
619
623
|
def ui_statusbar
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Parser for .gitignore files to determine which files should be ignored
|
|
5
|
+
class GitignoreParser
|
|
6
|
+
attr_reader :patterns
|
|
7
|
+
|
|
8
|
+
def initialize(gitignore_path = nil)
|
|
9
|
+
@patterns = []
|
|
10
|
+
@negation_patterns = []
|
|
11
|
+
|
|
12
|
+
if gitignore_path && File.exist?(gitignore_path)
|
|
13
|
+
parse_gitignore(gitignore_path)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Check if a file path should be ignored
|
|
18
|
+
def ignored?(path)
|
|
19
|
+
relative_path = path.start_with?('./') ? path[2..] : path
|
|
20
|
+
|
|
21
|
+
# Check negation patterns first (! prefix in .gitignore)
|
|
22
|
+
@negation_patterns.each do |pattern|
|
|
23
|
+
return false if match_pattern?(relative_path, pattern)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Then check ignore patterns
|
|
27
|
+
@patterns.each do |pattern|
|
|
28
|
+
return true if match_pattern?(relative_path, pattern)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse_gitignore(path)
|
|
37
|
+
File.readlines(path, chomp: true).each do |line|
|
|
38
|
+
# Skip comments and empty lines
|
|
39
|
+
next if line.strip.empty? || line.start_with?('#')
|
|
40
|
+
|
|
41
|
+
# Handle negation patterns (lines starting with !)
|
|
42
|
+
if line.start_with?('!')
|
|
43
|
+
@negation_patterns << normalize_pattern(line[1..])
|
|
44
|
+
else
|
|
45
|
+
@patterns << normalize_pattern(line)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
# If we can't parse .gitignore, just continue with empty patterns
|
|
50
|
+
warn "Warning: Failed to parse .gitignore: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_pattern(pattern)
|
|
54
|
+
pattern = pattern.strip
|
|
55
|
+
|
|
56
|
+
# Remove trailing whitespace
|
|
57
|
+
pattern = pattern.rstrip
|
|
58
|
+
|
|
59
|
+
# Store original for directory detection
|
|
60
|
+
is_directory = pattern.end_with?('/')
|
|
61
|
+
pattern = pattern.chomp('/')
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
pattern: pattern,
|
|
65
|
+
is_directory: is_directory,
|
|
66
|
+
is_absolute: pattern.start_with?('/'),
|
|
67
|
+
has_wildcard: pattern.include?('*') || pattern.include?('?'),
|
|
68
|
+
has_double_star: pattern.include?('**')
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def match_pattern?(path, pattern_info)
|
|
73
|
+
pattern = pattern_info[:pattern]
|
|
74
|
+
|
|
75
|
+
# Remove leading slash for absolute patterns
|
|
76
|
+
pattern = pattern[1..] if pattern_info[:is_absolute]
|
|
77
|
+
|
|
78
|
+
# Handle directory patterns
|
|
79
|
+
if pattern_info[:is_directory]
|
|
80
|
+
return false unless File.directory?(path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Handle different wildcard patterns
|
|
84
|
+
if pattern_info[:has_double_star]
|
|
85
|
+
# Convert ** to match any number of directories
|
|
86
|
+
regex_pattern = pattern
|
|
87
|
+
.gsub('**/', '(.*/)?') # **/ matches zero or more directories
|
|
88
|
+
.gsub('**', '.*') # ** at end matches anything
|
|
89
|
+
.gsub('*', '[^/]*') # * matches anything except /
|
|
90
|
+
.gsub('?', '[^/]') # ? matches single character except /
|
|
91
|
+
|
|
92
|
+
regex = Regexp.new("^#{regex_pattern}$")
|
|
93
|
+
return true if path.match?(regex)
|
|
94
|
+
return true if path.split('/').any? { |part| part.match?(regex) }
|
|
95
|
+
elsif pattern_info[:has_wildcard]
|
|
96
|
+
# Convert glob pattern to regex
|
|
97
|
+
regex_pattern = pattern
|
|
98
|
+
.gsub('*', '[^/]*')
|
|
99
|
+
.gsub('?', '[^/]')
|
|
100
|
+
|
|
101
|
+
regex = Regexp.new("^#{regex_pattern}$")
|
|
102
|
+
return true if path.match?(regex)
|
|
103
|
+
return true if File.basename(path).match?(regex)
|
|
104
|
+
else
|
|
105
|
+
# Exact match
|
|
106
|
+
return true if path == pattern
|
|
107
|
+
return true if path.start_with?("#{pattern}/")
|
|
108
|
+
return true if File.basename(path) == pattern
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
module Clacky
|
|
4
4
|
module Tools
|
|
5
5
|
class Grep < Base
|
|
6
|
+
# Default patterns to ignore when .gitignore is not available
|
|
7
|
+
DEFAULT_IGNORED_PATTERNS = [
|
|
8
|
+
'node_modules',
|
|
9
|
+
'vendor/bundle',
|
|
10
|
+
'.git',
|
|
11
|
+
'.svn',
|
|
12
|
+
'tmp',
|
|
13
|
+
'log',
|
|
14
|
+
'coverage',
|
|
15
|
+
'dist',
|
|
16
|
+
'build',
|
|
17
|
+
'.bundle',
|
|
18
|
+
'.sass-cache',
|
|
19
|
+
'.DS_Store',
|
|
20
|
+
'*.log'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Config file patterns that should always be searchable
|
|
24
|
+
CONFIG_FILE_PATTERNS = [
|
|
25
|
+
/\.env/,
|
|
26
|
+
/\.ya?ml$/,
|
|
27
|
+
/\.json$/,
|
|
28
|
+
/\.toml$/,
|
|
29
|
+
/\.ini$/,
|
|
30
|
+
/\.conf$/,
|
|
31
|
+
/\.config$/,
|
|
32
|
+
/config\//,
|
|
33
|
+
/\.config\//
|
|
34
|
+
].freeze
|
|
35
|
+
|
|
36
|
+
# Maximum file size to search (1MB)
|
|
37
|
+
MAX_FILE_SIZE = 1_048_576
|
|
38
|
+
|
|
39
|
+
# Maximum line length to display (to avoid huge outputs)
|
|
40
|
+
MAX_LINE_LENGTH = 500
|
|
41
|
+
|
|
6
42
|
self.tool_name = "grep"
|
|
7
43
|
self.tool_description = "Search file contents using regular expressions. Returns matching lines with context."
|
|
8
44
|
self.tool_category = "file_system"
|
|
@@ -30,53 +66,144 @@ module Clacky
|
|
|
30
66
|
},
|
|
31
67
|
context_lines: {
|
|
32
68
|
type: "integer",
|
|
33
|
-
description: "Number of context lines to show before and after each match",
|
|
69
|
+
description: "Number of context lines to show before and after each match (max: 10)",
|
|
34
70
|
default: 0
|
|
35
71
|
},
|
|
36
|
-
|
|
72
|
+
max_files: {
|
|
37
73
|
type: "integer",
|
|
38
74
|
description: "Maximum number of matching files to return",
|
|
39
75
|
default: 50
|
|
76
|
+
},
|
|
77
|
+
max_matches_per_file: {
|
|
78
|
+
type: "integer",
|
|
79
|
+
description: "Maximum number of matches to return per file",
|
|
80
|
+
default: 50
|
|
81
|
+
},
|
|
82
|
+
max_total_matches: {
|
|
83
|
+
type: "integer",
|
|
84
|
+
description: "Maximum total number of matches to return across all files",
|
|
85
|
+
default: 200
|
|
86
|
+
},
|
|
87
|
+
max_file_size: {
|
|
88
|
+
type: "integer",
|
|
89
|
+
description: "Maximum file size in bytes to search (default: 1MB)",
|
|
90
|
+
default: MAX_FILE_SIZE
|
|
91
|
+
},
|
|
92
|
+
max_files_to_search: {
|
|
93
|
+
type: "integer",
|
|
94
|
+
description: "Maximum number of files to search",
|
|
95
|
+
default: 500
|
|
40
96
|
}
|
|
41
97
|
},
|
|
42
98
|
required: %w[pattern]
|
|
43
99
|
}
|
|
44
100
|
|
|
45
|
-
def execute(
|
|
101
|
+
def execute(
|
|
102
|
+
pattern:,
|
|
103
|
+
path: ".",
|
|
104
|
+
file_pattern: "**/*",
|
|
105
|
+
case_insensitive: false,
|
|
106
|
+
context_lines: 0,
|
|
107
|
+
max_files: 50,
|
|
108
|
+
max_matches_per_file: 50,
|
|
109
|
+
max_total_matches: 200,
|
|
110
|
+
max_file_size: MAX_FILE_SIZE,
|
|
111
|
+
max_files_to_search: 500
|
|
112
|
+
)
|
|
46
113
|
# Validate pattern
|
|
47
114
|
if pattern.nil? || pattern.strip.empty?
|
|
48
115
|
return { error: "Pattern cannot be empty" }
|
|
49
116
|
end
|
|
50
117
|
|
|
51
|
-
# Validate path
|
|
52
|
-
|
|
118
|
+
# Validate and expand path
|
|
119
|
+
begin
|
|
120
|
+
expanded_path = File.expand_path(path)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
return { error: "Invalid path: #{e.message}" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
unless File.exist?(expanded_path)
|
|
53
126
|
return { error: "Path does not exist: #{path}" }
|
|
54
127
|
end
|
|
55
128
|
|
|
129
|
+
# Limit context_lines
|
|
130
|
+
context_lines = [[context_lines, 0].max, 10].min
|
|
131
|
+
|
|
56
132
|
begin
|
|
57
133
|
# Compile regex
|
|
58
134
|
regex_options = case_insensitive ? Regexp::IGNORECASE : 0
|
|
59
135
|
regex = Regexp.new(pattern, regex_options)
|
|
60
136
|
|
|
137
|
+
# Initialize gitignore parser
|
|
138
|
+
gitignore_path = find_gitignore(expanded_path)
|
|
139
|
+
gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil
|
|
140
|
+
|
|
61
141
|
results = []
|
|
62
142
|
total_matches = 0
|
|
143
|
+
files_searched = 0
|
|
144
|
+
skipped = {
|
|
145
|
+
binary: 0,
|
|
146
|
+
too_large: 0,
|
|
147
|
+
ignored: 0
|
|
148
|
+
}
|
|
149
|
+
truncation_reason = nil
|
|
63
150
|
|
|
64
151
|
# Get files to search
|
|
65
|
-
files = if File.file?(
|
|
66
|
-
[
|
|
152
|
+
files = if File.file?(expanded_path)
|
|
153
|
+
[expanded_path]
|
|
67
154
|
else
|
|
68
|
-
Dir.glob(File.join(
|
|
155
|
+
Dir.glob(File.join(expanded_path, file_pattern))
|
|
69
156
|
.select { |f| File.file?(f) }
|
|
70
|
-
.reject { |f| binary_file?(f) }
|
|
71
157
|
end
|
|
72
158
|
|
|
73
159
|
# Search each file
|
|
74
160
|
files.each do |file|
|
|
75
|
-
|
|
161
|
+
# Check if we've searched enough files
|
|
162
|
+
if files_searched >= max_files_to_search
|
|
163
|
+
truncation_reason ||= "max_files_to_search limit reached"
|
|
164
|
+
break
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Skip if file should be ignored (unless it's a config file)
|
|
168
|
+
if should_ignore_file?(file, expanded_path, gitignore) && !is_config_file?(file)
|
|
169
|
+
skipped[:ignored] += 1
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Skip binary files
|
|
174
|
+
if binary_file?(file)
|
|
175
|
+
skipped[:binary] += 1
|
|
176
|
+
next
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Skip files that are too large
|
|
180
|
+
if File.size(file) > max_file_size
|
|
181
|
+
skipped[:too_large] += 1
|
|
182
|
+
next
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
files_searched += 1
|
|
186
|
+
|
|
187
|
+
# Check if we've found enough matching files
|
|
188
|
+
if results.length >= max_files
|
|
189
|
+
truncation_reason ||= "max_files limit reached"
|
|
190
|
+
break
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Check if we've found enough total matches
|
|
194
|
+
if total_matches >= max_total_matches
|
|
195
|
+
truncation_reason ||= "max_total_matches limit reached"
|
|
196
|
+
break
|
|
197
|
+
end
|
|
76
198
|
|
|
77
|
-
|
|
199
|
+
# Search the file
|
|
200
|
+
matches = search_file(file, regex, context_lines, max_matches_per_file)
|
|
78
201
|
next if matches.empty?
|
|
79
202
|
|
|
203
|
+
# Add remaining matches respecting max_total_matches
|
|
204
|
+
remaining_matches = max_total_matches - total_matches
|
|
205
|
+
matches = matches.take(remaining_matches) if remaining_matches < matches.length
|
|
206
|
+
|
|
80
207
|
results << {
|
|
81
208
|
file: File.expand_path(file),
|
|
82
209
|
matches: matches
|
|
@@ -87,9 +214,11 @@ module Clacky
|
|
|
87
214
|
{
|
|
88
215
|
results: results,
|
|
89
216
|
total_matches: total_matches,
|
|
90
|
-
files_searched:
|
|
217
|
+
files_searched: files_searched,
|
|
91
218
|
files_with_matches: results.length,
|
|
92
|
-
|
|
219
|
+
skipped_files: skipped,
|
|
220
|
+
truncated: !truncation_reason.nil?,
|
|
221
|
+
truncation_reason: truncation_reason,
|
|
93
222
|
error: nil
|
|
94
223
|
}
|
|
95
224
|
rescue RegexpError => e
|
|
@@ -116,36 +245,96 @@ module Clacky
|
|
|
116
245
|
else
|
|
117
246
|
matches = result[:total_matches] || 0
|
|
118
247
|
files = result[:files_with_matches] || 0
|
|
119
|
-
"✓ Found #{matches} matches in #{files} files"
|
|
248
|
+
msg = "✓ Found #{matches} matches in #{files} files"
|
|
249
|
+
|
|
250
|
+
# Add truncation info if present
|
|
251
|
+
if result[:truncated] && result[:truncation_reason]
|
|
252
|
+
msg += " (truncated: #{result[:truncation_reason]})"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
msg
|
|
120
256
|
end
|
|
121
257
|
end
|
|
122
258
|
|
|
123
259
|
private
|
|
124
260
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
261
|
+
# Find .gitignore file in the search path or parent directories
|
|
262
|
+
def find_gitignore(path)
|
|
263
|
+
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
264
|
+
|
|
265
|
+
# Look for .gitignore in current and parent directories
|
|
266
|
+
current = File.expand_path(search_path)
|
|
267
|
+
root = File.expand_path('/')
|
|
268
|
+
|
|
269
|
+
loop do
|
|
270
|
+
gitignore = File.join(current, '.gitignore')
|
|
271
|
+
return gitignore if File.exist?(gitignore)
|
|
272
|
+
|
|
273
|
+
break if current == root
|
|
274
|
+
current = File.dirname(current)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Check if file should be ignored based on .gitignore or default patterns
|
|
281
|
+
def should_ignore_file?(file, base_path, gitignore)
|
|
282
|
+
# Calculate relative path
|
|
283
|
+
if file.start_with?(base_path)
|
|
284
|
+
relative_path = file[base_path.length + 1..] || file
|
|
285
|
+
else
|
|
286
|
+
relative_path = file
|
|
287
|
+
end
|
|
288
|
+
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
289
|
+
relative_path ||= file
|
|
290
|
+
|
|
291
|
+
if gitignore
|
|
292
|
+
# Use .gitignore rules
|
|
293
|
+
gitignore.ignored?(relative_path)
|
|
294
|
+
else
|
|
295
|
+
# Use default ignore patterns
|
|
296
|
+
DEFAULT_IGNORED_PATTERNS.any? do |pattern|
|
|
297
|
+
if pattern.include?('*')
|
|
298
|
+
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
299
|
+
else
|
|
300
|
+
relative_path.start_with?("#{pattern}/") ||
|
|
301
|
+
relative_path.include?("/#{pattern}/") ||
|
|
302
|
+
relative_path == pattern ||
|
|
303
|
+
File.basename(relative_path) == pattern
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
128
308
|
|
|
129
|
-
|
|
309
|
+
# Check if file is a config file (should not be ignored even if in .gitignore)
|
|
310
|
+
def is_config_file?(file)
|
|
311
|
+
CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def search_file(file, regex, context_lines, max_matches)
|
|
315
|
+
matches = []
|
|
316
|
+
|
|
317
|
+
# Use File.foreach for memory-efficient line-by-line reading
|
|
318
|
+
File.foreach(file, chomp: true).with_index do |line, index|
|
|
319
|
+
# Stop if we have enough matches for this file
|
|
320
|
+
break if matches.length >= max_matches
|
|
321
|
+
|
|
130
322
|
next unless line.match?(regex)
|
|
131
323
|
|
|
132
|
-
#
|
|
133
|
-
|
|
134
|
-
end_line = [lines.length - 1, index + context_lines].min
|
|
324
|
+
# Truncate long lines
|
|
325
|
+
display_line = line.length > MAX_LINE_LENGTH ? "#{line[0...MAX_LINE_LENGTH]}..." : line
|
|
135
326
|
|
|
136
|
-
context
|
|
137
|
-
|
|
138
|
-
context
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
is_match: i == index
|
|
142
|
-
}
|
|
327
|
+
# Get context if requested
|
|
328
|
+
if context_lines > 0
|
|
329
|
+
context = get_line_context(file, index, context_lines)
|
|
330
|
+
else
|
|
331
|
+
context = nil
|
|
143
332
|
end
|
|
144
333
|
|
|
145
334
|
matches << {
|
|
146
335
|
line_number: index + 1,
|
|
147
|
-
line:
|
|
148
|
-
context:
|
|
336
|
+
line: display_line,
|
|
337
|
+
context: context
|
|
149
338
|
}
|
|
150
339
|
end
|
|
151
340
|
|
|
@@ -154,6 +343,32 @@ module Clacky
|
|
|
154
343
|
[]
|
|
155
344
|
end
|
|
156
345
|
|
|
346
|
+
# Get context lines around a match
|
|
347
|
+
def get_line_context(file, match_index, context_lines)
|
|
348
|
+
lines = File.readlines(file, chomp: true)
|
|
349
|
+
start_line = [0, match_index - context_lines].max
|
|
350
|
+
end_line = [lines.length - 1, match_index + context_lines].min
|
|
351
|
+
|
|
352
|
+
context = []
|
|
353
|
+
(start_line..end_line).each do |i|
|
|
354
|
+
line_content = lines[i]
|
|
355
|
+
# Truncate long lines in context too
|
|
356
|
+
display_content = line_content.length > MAX_LINE_LENGTH ?
|
|
357
|
+
"#{line_content[0...MAX_LINE_LENGTH]}..." :
|
|
358
|
+
line_content
|
|
359
|
+
|
|
360
|
+
context << {
|
|
361
|
+
line_number: i + 1,
|
|
362
|
+
content: display_content,
|
|
363
|
+
is_match: i == match_index
|
|
364
|
+
}
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
context
|
|
368
|
+
rescue StandardError
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
157
372
|
def binary_file?(file)
|
|
158
373
|
# Simple heuristic: check if file contains null bytes in first 8KB
|
|
159
374
|
return false unless File.exist?(file)
|
|
@@ -0,0 +1,643 @@
|
|
|
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: "You:")
|
|
32
|
+
# @return [Hash, nil] { text: String, images: Array } or nil on EOF
|
|
33
|
+
def read_input(prefix: "You:")
|
|
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 box
|
|
42
|
+
display_prompt_box(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_prompt_display(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_prompt_display(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_prompt_display(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
|
+
# Expand placeholders to actual pasted content
|
|
230
|
+
def expand_placeholders(text)
|
|
231
|
+
result = text.dup
|
|
232
|
+
@paste_placeholders.each do |placeholder, actual_content|
|
|
233
|
+
result.gsub!(placeholder, actual_content)
|
|
234
|
+
end
|
|
235
|
+
result
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Display the prompt box with images and input
|
|
239
|
+
def display_prompt_box(lines, prefix, line_index, cursor_pos)
|
|
240
|
+
width = TTY::Screen.width - 4 # Use full terminal width (minus 4 for borders)
|
|
241
|
+
|
|
242
|
+
# Clear previous display if exists
|
|
243
|
+
if @last_display_lines && @last_display_lines > 0
|
|
244
|
+
# Move cursor up and clear each line
|
|
245
|
+
@last_display_lines.times do
|
|
246
|
+
print "\e[1A" # Move up one line
|
|
247
|
+
print "\e[2K" # Clear entire line
|
|
248
|
+
end
|
|
249
|
+
print "\r" # Move to beginning of line
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
lines_to_display = []
|
|
253
|
+
|
|
254
|
+
# Display images if any
|
|
255
|
+
if @images.any?
|
|
256
|
+
lines_to_display << @pastel.dim("╭─ Attached Images " + "─" * (width - 19) + "╮")
|
|
257
|
+
@images.each_with_index do |img_path, idx|
|
|
258
|
+
filename = File.basename(img_path)
|
|
259
|
+
# Check if file exists before getting size
|
|
260
|
+
filesize = File.exist?(img_path) ? format_filesize(File.size(img_path)) : "N/A"
|
|
261
|
+
line_content = " #{idx + 1}. #{filename} (#{filesize})"
|
|
262
|
+
display_content = line_content.ljust(width - 2)
|
|
263
|
+
lines_to_display << @pastel.dim("│ ") + display_content + @pastel.dim(" │")
|
|
264
|
+
end
|
|
265
|
+
lines_to_display << @pastel.dim("╰" + "─" * width + "╯")
|
|
266
|
+
lines_to_display << ""
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Display input box
|
|
270
|
+
hint = "Shift+Enter:newline | Enter:submit | Ctrl+C:cancel"
|
|
271
|
+
lines_to_display << @pastel.dim("╭─ Message " + "─" * (width - 10) + "╮")
|
|
272
|
+
hint_line = @pastel.dim(hint)
|
|
273
|
+
padding = " " * [(width - hint.length - 2), 0].max
|
|
274
|
+
lines_to_display << @pastel.dim("│ ") + hint_line + padding + @pastel.dim(" │")
|
|
275
|
+
lines_to_display << @pastel.dim("├" + "─" * width + "┤")
|
|
276
|
+
|
|
277
|
+
# Display input lines with word wrap
|
|
278
|
+
display_lines = lines.empty? ? [""] : lines
|
|
279
|
+
max_display_lines = 15 # Show up to 15 wrapped lines
|
|
280
|
+
|
|
281
|
+
# Flatten all lines with word wrap
|
|
282
|
+
wrapped_display_lines = []
|
|
283
|
+
line_to_wrapped_mapping = [] # Track which original line each wrapped line belongs to
|
|
284
|
+
|
|
285
|
+
display_lines.each_with_index do |line, original_idx|
|
|
286
|
+
line_chars = line.chars
|
|
287
|
+
content_width = width - 2 # Available width for content (excluding borders)
|
|
288
|
+
|
|
289
|
+
if line_chars.length <= content_width
|
|
290
|
+
# Line fits in one display line
|
|
291
|
+
wrapped_display_lines << { text: line, original_line: original_idx, start_pos: 0 }
|
|
292
|
+
else
|
|
293
|
+
# Line needs wrapping
|
|
294
|
+
start_pos = 0
|
|
295
|
+
while start_pos < line_chars.length
|
|
296
|
+
chunk_chars = line_chars[start_pos...[start_pos + content_width, line_chars.length].min]
|
|
297
|
+
wrapped_display_lines << {
|
|
298
|
+
text: chunk_chars.join,
|
|
299
|
+
original_line: original_idx,
|
|
300
|
+
start_pos: start_pos
|
|
301
|
+
}
|
|
302
|
+
start_pos += content_width
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Find which wrapped line contains the cursor
|
|
308
|
+
cursor_wrapped_line_idx = 0
|
|
309
|
+
cursor_in_wrapped_pos = cursor_pos
|
|
310
|
+
content_width = width - 2
|
|
311
|
+
|
|
312
|
+
# Find all wrapped lines for the current line_index
|
|
313
|
+
current_line_wrapped = wrapped_display_lines.select.with_index { |wl, idx| wl[:original_line] == line_index }
|
|
314
|
+
|
|
315
|
+
if current_line_wrapped.any?
|
|
316
|
+
# Iterate through wrapped lines to find where cursor belongs
|
|
317
|
+
accumulated_chars = 0
|
|
318
|
+
found = false
|
|
319
|
+
|
|
320
|
+
current_line_wrapped.each_with_index do |wrapped_line, local_idx|
|
|
321
|
+
line_start = wrapped_line[:start_pos]
|
|
322
|
+
line_length = wrapped_line[:text].chars.length
|
|
323
|
+
line_end = line_start + line_length
|
|
324
|
+
|
|
325
|
+
# Find global index of this wrapped line
|
|
326
|
+
global_idx = wrapped_display_lines.index { |wl| wl == wrapped_line }
|
|
327
|
+
|
|
328
|
+
if cursor_pos >= line_start && cursor_pos < line_end
|
|
329
|
+
# Cursor is within this wrapped line
|
|
330
|
+
cursor_wrapped_line_idx = global_idx
|
|
331
|
+
cursor_in_wrapped_pos = cursor_pos - line_start
|
|
332
|
+
found = true
|
|
333
|
+
break
|
|
334
|
+
elsif cursor_pos == line_end && local_idx == current_line_wrapped.length - 1
|
|
335
|
+
# Cursor is at the very end of the last wrapped line for this line_index
|
|
336
|
+
cursor_wrapped_line_idx = global_idx
|
|
337
|
+
cursor_in_wrapped_pos = line_length
|
|
338
|
+
found = true
|
|
339
|
+
break
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Fallback: if not found, place cursor at the end of the last wrapped line
|
|
344
|
+
unless found
|
|
345
|
+
last_wrapped = current_line_wrapped.last
|
|
346
|
+
cursor_wrapped_line_idx = wrapped_display_lines.index { |wl| wl == last_wrapped }
|
|
347
|
+
cursor_in_wrapped_pos = last_wrapped[:text].chars.length
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Determine which wrapped lines to display (centered around cursor)
|
|
352
|
+
if wrapped_display_lines.size <= max_display_lines
|
|
353
|
+
display_start = 0
|
|
354
|
+
display_end = wrapped_display_lines.size - 1
|
|
355
|
+
else
|
|
356
|
+
# Center view around cursor line
|
|
357
|
+
half_display = max_display_lines / 2
|
|
358
|
+
display_start = [cursor_wrapped_line_idx - half_display, 0].max
|
|
359
|
+
display_end = [display_start + max_display_lines - 1, wrapped_display_lines.size - 1].min
|
|
360
|
+
|
|
361
|
+
# Adjust if we're near the end
|
|
362
|
+
if display_end - display_start < max_display_lines - 1
|
|
363
|
+
display_start = [display_end - max_display_lines + 1, 0].max
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Display the wrapped lines
|
|
368
|
+
(display_start..display_end).each do |idx|
|
|
369
|
+
wrapped_line = wrapped_display_lines[idx]
|
|
370
|
+
line_text = wrapped_line[:text]
|
|
371
|
+
line_chars = line_text.chars
|
|
372
|
+
content_width = width - 2
|
|
373
|
+
|
|
374
|
+
# Pad to full width
|
|
375
|
+
display_line = line_text.ljust(content_width)
|
|
376
|
+
|
|
377
|
+
if idx == cursor_wrapped_line_idx
|
|
378
|
+
# Show cursor on this wrapped line
|
|
379
|
+
before_cursor = line_chars[0...cursor_in_wrapped_pos].join
|
|
380
|
+
cursor_char = line_chars[cursor_in_wrapped_pos] || " "
|
|
381
|
+
after_cursor_chars = line_chars[(cursor_in_wrapped_pos + 1)..-1]
|
|
382
|
+
after_cursor = after_cursor_chars ? after_cursor_chars.join : ""
|
|
383
|
+
|
|
384
|
+
# Calculate padding
|
|
385
|
+
content_length = before_cursor.length + 1 + after_cursor.length
|
|
386
|
+
padding = " " * [content_width - content_length, 0].max
|
|
387
|
+
|
|
388
|
+
line_display = before_cursor + @pastel.on_white(@pastel.black(cursor_char)) + after_cursor + padding
|
|
389
|
+
lines_to_display << @pastel.dim("│ ") + line_display + @pastel.dim(" │")
|
|
390
|
+
else
|
|
391
|
+
lines_to_display << @pastel.dim("│ ") + display_line + @pastel.dim(" │")
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Show scroll indicator if needed
|
|
396
|
+
if wrapped_display_lines.size > max_display_lines
|
|
397
|
+
scroll_info = " (#{display_start + 1}-#{display_end + 1}/#{wrapped_display_lines.size} lines) "
|
|
398
|
+
lines_to_display << @pastel.dim("│#{scroll_info.center(width)}│")
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Footer - calculate width properly
|
|
402
|
+
footer_text = "Line #{line_index + 1}/#{display_lines.size} | Char #{cursor_pos}/#{(display_lines[line_index] || "").chars.length}"
|
|
403
|
+
# Total width = "╰─ " (3) + footer_text + " ─...─╯" (width - 3 - footer_text.length)
|
|
404
|
+
remaining_width = width - footer_text.length - 3 # 3 = "╰─ " length
|
|
405
|
+
footer_line = @pastel.dim("╰─ ") + @pastel.dim(footer_text) + @pastel.dim(" ") + @pastel.dim("─" * [remaining_width - 1, 0].max) + @pastel.dim("╯")
|
|
406
|
+
lines_to_display << footer_line
|
|
407
|
+
|
|
408
|
+
# Output all lines at once (use print to avoid extra newline at the end)
|
|
409
|
+
print lines_to_display.join("\n")
|
|
410
|
+
print "\n" # Add one controlled newline
|
|
411
|
+
|
|
412
|
+
# Remember how many lines we displayed
|
|
413
|
+
@last_display_lines = lines_to_display.size
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Clear prompt display after submission
|
|
417
|
+
def clear_prompt_display(num_lines)
|
|
418
|
+
# Clear the prompt box we just displayed
|
|
419
|
+
if @last_display_lines && @last_display_lines > 0
|
|
420
|
+
@last_display_lines.times do
|
|
421
|
+
print "\e[1A" # Move up one line
|
|
422
|
+
print "\e[2K" # Clear entire line
|
|
423
|
+
end
|
|
424
|
+
print "\r" # Move to beginning of line
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Read a single key press with escape sequence handling
|
|
429
|
+
# Handles UTF-8 multi-byte characters correctly
|
|
430
|
+
# Also detects rapid input (paste-like behavior)
|
|
431
|
+
def read_key_with_rapid_detection
|
|
432
|
+
$stdin.set_encoding('UTF-8')
|
|
433
|
+
|
|
434
|
+
current_time = Time.now.to_f
|
|
435
|
+
is_rapid_input = @last_input_time && (current_time - @last_input_time) < @rapid_input_threshold
|
|
436
|
+
@last_input_time = current_time
|
|
437
|
+
|
|
438
|
+
$stdin.raw do |io|
|
|
439
|
+
io.set_encoding('UTF-8') # Ensure IO encoding is UTF-8
|
|
440
|
+
c = io.getc
|
|
441
|
+
|
|
442
|
+
# Ensure character is UTF-8 encoded
|
|
443
|
+
c = c.force_encoding('UTF-8') if c.is_a?(String) && c.encoding != Encoding::UTF_8
|
|
444
|
+
|
|
445
|
+
# Handle escape sequences (arrow keys, special keys)
|
|
446
|
+
if c == "\e"
|
|
447
|
+
# Read the next 2 characters for escape sequences
|
|
448
|
+
begin
|
|
449
|
+
extra = io.read_nonblock(2)
|
|
450
|
+
extra = extra.force_encoding('UTF-8') if extra.encoding != Encoding::UTF_8
|
|
451
|
+
c = c + extra
|
|
452
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
|
453
|
+
# No more characters available
|
|
454
|
+
end
|
|
455
|
+
return c
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
# Check if there are more characters available using IO.select with timeout 0
|
|
459
|
+
has_more_input = IO.select([io], nil, nil, 0)
|
|
460
|
+
|
|
461
|
+
# If this is rapid input or there are more characters available
|
|
462
|
+
if is_rapid_input || has_more_input
|
|
463
|
+
# Buffer rapid input
|
|
464
|
+
buffer = c.to_s.dup
|
|
465
|
+
buffer.force_encoding('UTF-8')
|
|
466
|
+
|
|
467
|
+
# Keep reading available characters
|
|
468
|
+
loop do
|
|
469
|
+
begin
|
|
470
|
+
next_char = io.read_nonblock(1)
|
|
471
|
+
next_char = next_char.force_encoding('UTF-8') if next_char.encoding != Encoding::UTF_8
|
|
472
|
+
buffer << next_char
|
|
473
|
+
|
|
474
|
+
# Continue only if more characters are immediately available
|
|
475
|
+
break unless IO.select([io], nil, nil, 0)
|
|
476
|
+
rescue IO::WaitReadable, Errno::EAGAIN
|
|
477
|
+
break
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Ensure buffer is UTF-8
|
|
482
|
+
buffer.force_encoding('UTF-8')
|
|
483
|
+
|
|
484
|
+
# If we buffered multiple characters or newlines, treat as rapid input (paste)
|
|
485
|
+
if buffer.length > 1 || buffer.include?("\n") || buffer.include?("\r")
|
|
486
|
+
# Remove any trailing \r or \n from rapid input buffer
|
|
487
|
+
cleaned_buffer = buffer.gsub(/[\r\n]+\z/, '')
|
|
488
|
+
return { type: :rapid_input, text: cleaned_buffer } if cleaned_buffer.length > 0
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Single character rapid input, return as-is
|
|
492
|
+
return buffer[0] if buffer.length == 1
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
c
|
|
496
|
+
end
|
|
497
|
+
rescue Errno::EINTR
|
|
498
|
+
"\u0003" # Treat interrupt as Ctrl+C
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Legacy method for compatibility
|
|
502
|
+
def read_key
|
|
503
|
+
read_key_with_rapid_detection
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Paste from clipboard (cross-platform)
|
|
507
|
+
# @return [Hash] { type: :text/:image, text: String, path: String }
|
|
508
|
+
def paste_from_clipboard
|
|
509
|
+
case RbConfig::CONFIG["host_os"]
|
|
510
|
+
when /darwin/i
|
|
511
|
+
paste_from_clipboard_macos
|
|
512
|
+
when /linux/i
|
|
513
|
+
paste_from_clipboard_linux
|
|
514
|
+
when /mswin|mingw|cygwin/i
|
|
515
|
+
paste_from_clipboard_windows
|
|
516
|
+
else
|
|
517
|
+
{ type: :text, text: "" }
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# Paste from macOS clipboard
|
|
522
|
+
def paste_from_clipboard_macos
|
|
523
|
+
require 'shellwords'
|
|
524
|
+
require 'fileutils'
|
|
525
|
+
|
|
526
|
+
# First check if there's an image in clipboard
|
|
527
|
+
# Use osascript to check clipboard content type
|
|
528
|
+
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")
|
|
529
|
+
|
|
530
|
+
if has_image
|
|
531
|
+
# Create a persistent temporary file (won't be auto-deleted)
|
|
532
|
+
temp_dir = Dir.tmpdir
|
|
533
|
+
temp_filename = "clipboard-#{Time.now.to_i}-#{rand(10000)}.png"
|
|
534
|
+
temp_path = File.join(temp_dir, temp_filename)
|
|
535
|
+
|
|
536
|
+
# Extract image using osascript
|
|
537
|
+
script = <<~APPLESCRIPT
|
|
538
|
+
set png_data to the clipboard as «class PNGf»
|
|
539
|
+
set the_file to open for access POSIX file "#{temp_path}" with write permission
|
|
540
|
+
write png_data to the_file
|
|
541
|
+
close access the_file
|
|
542
|
+
APPLESCRIPT
|
|
543
|
+
|
|
544
|
+
success = system("osascript", "-e", script, out: File::NULL, err: File::NULL)
|
|
545
|
+
|
|
546
|
+
if success && File.exist?(temp_path) && File.size(temp_path) > 0
|
|
547
|
+
return { type: :image, path: temp_path }
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# No image, try text - ensure UTF-8 encoding
|
|
552
|
+
text = `pbpaste 2>/dev/null`.to_s
|
|
553
|
+
text.force_encoding('UTF-8')
|
|
554
|
+
# Replace invalid UTF-8 sequences with replacement character
|
|
555
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
556
|
+
{ type: :text, text: text }
|
|
557
|
+
rescue => e
|
|
558
|
+
# Fallback to empty text on error
|
|
559
|
+
{ type: :text, text: "" }
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Paste from Linux clipboard
|
|
563
|
+
def paste_from_clipboard_linux
|
|
564
|
+
require 'shellwords'
|
|
565
|
+
|
|
566
|
+
# Check if xclip is available
|
|
567
|
+
if system("which xclip >/dev/null 2>&1")
|
|
568
|
+
# Try to get image first
|
|
569
|
+
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
570
|
+
temp_file.close
|
|
571
|
+
|
|
572
|
+
# Try different image MIME types
|
|
573
|
+
["image/png", "image/jpeg", "image/jpg"].each do |mime_type|
|
|
574
|
+
if system("xclip -selection clipboard -t #{mime_type} -o > #{Shellwords.escape(temp_file.path)} 2>/dev/null")
|
|
575
|
+
if File.size(temp_file.path) > 0
|
|
576
|
+
return { type: :image, path: temp_file.path }
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# No image, get text - ensure UTF-8 encoding
|
|
582
|
+
text = `xclip -selection clipboard -o 2>/dev/null`.to_s
|
|
583
|
+
text.force_encoding('UTF-8')
|
|
584
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
585
|
+
{ type: :text, text: text }
|
|
586
|
+
elsif system("which xsel >/dev/null 2>&1")
|
|
587
|
+
# Fallback to xsel for text only
|
|
588
|
+
text = `xsel --clipboard --output 2>/dev/null`.to_s
|
|
589
|
+
text.force_encoding('UTF-8')
|
|
590
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
591
|
+
{ type: :text, text: text }
|
|
592
|
+
else
|
|
593
|
+
{ type: :text, text: "" }
|
|
594
|
+
end
|
|
595
|
+
rescue => e
|
|
596
|
+
{ type: :text, text: "" }
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Paste from Windows clipboard
|
|
600
|
+
def paste_from_clipboard_windows
|
|
601
|
+
# Try to get image using PowerShell
|
|
602
|
+
temp_file = Tempfile.new(["clipboard-", ".png"])
|
|
603
|
+
temp_file.close
|
|
604
|
+
|
|
605
|
+
ps_script = <<~POWERSHELL
|
|
606
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
607
|
+
$img = [Windows.Forms.Clipboard]::GetImage()
|
|
608
|
+
if ($img) {
|
|
609
|
+
$img.Save('#{temp_file.path.gsub("'", "''")}', [System.Drawing.Imaging.ImageFormat]::Png)
|
|
610
|
+
exit 0
|
|
611
|
+
} else {
|
|
612
|
+
exit 1
|
|
613
|
+
}
|
|
614
|
+
POWERSHELL
|
|
615
|
+
|
|
616
|
+
success = system("powershell", "-NoProfile", "-Command", ps_script, out: File::NULL, err: File::NULL)
|
|
617
|
+
|
|
618
|
+
if success && File.exist?(temp_file.path) && File.size(temp_file.path) > 0
|
|
619
|
+
return { type: :image, path: temp_file.path }
|
|
620
|
+
end
|
|
621
|
+
|
|
622
|
+
# No image, get text - ensure UTF-8 encoding
|
|
623
|
+
text = `powershell -NoProfile -Command "Get-Clipboard" 2>nul`.to_s
|
|
624
|
+
text.force_encoding('UTF-8')
|
|
625
|
+
text = text.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
626
|
+
{ type: :text, text: text }
|
|
627
|
+
rescue => e
|
|
628
|
+
{ type: :text, text: "" }
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Format file size for display
|
|
632
|
+
def format_filesize(size)
|
|
633
|
+
if size < 1024
|
|
634
|
+
"#{size}B"
|
|
635
|
+
elsif size < 1024 * 1024
|
|
636
|
+
"#{(size / 1024.0).round(1)}KB"
|
|
637
|
+
else
|
|
638
|
+
"#{(size / 1024.0 / 1024.0).round(1)}MB"
|
|
639
|
+
end
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
end
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky.rb
CHANGED
|
@@ -12,6 +12,7 @@ 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.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- windy
|
|
@@ -137,6 +137,7 @@ files:
|
|
|
137
137
|
- lib/clacky/client.rb
|
|
138
138
|
- lib/clacky/config.rb
|
|
139
139
|
- lib/clacky/conversation.rb
|
|
140
|
+
- lib/clacky/gitignore_parser.rb
|
|
140
141
|
- lib/clacky/hook_manager.rb
|
|
141
142
|
- lib/clacky/progress_indicator.rb
|
|
142
143
|
- lib/clacky/session_manager.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/ui/prompt.rb
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "tty-prompt"
|
|
4
|
-
require "pastel"
|
|
5
|
-
require "tty-screen"
|
|
6
|
-
|
|
7
|
-
module Clacky
|
|
8
|
-
module UI
|
|
9
|
-
# Enhanced input prompt with box drawing and status info
|
|
10
|
-
class Prompt
|
|
11
|
-
def initialize
|
|
12
|
-
@pastel = Pastel.new
|
|
13
|
-
@tty_prompt = TTY::Prompt.new(interrupt: :exit)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
# Read user input with enhanced prompt box
|
|
17
|
-
# @param prefix [String] Prompt prefix (default: "You:")
|
|
18
|
-
# @param placeholder [String] Placeholder text (not shown when using TTY::Prompt)
|
|
19
|
-
# @return [String, nil] User input or nil on EOF
|
|
20
|
-
def read_input(prefix: "You:", placeholder: nil)
|
|
21
|
-
width = [TTY::Screen.width - 5, 70].min
|
|
22
|
-
|
|
23
|
-
# Display complete box frame first
|
|
24
|
-
puts @pastel.dim("╭" + "─" * width + "╮")
|
|
25
|
-
|
|
26
|
-
# Empty input line - NO left border, just spaces and right border
|
|
27
|
-
padding = " " * width
|
|
28
|
-
puts @pastel.dim("#{padding} │")
|
|
29
|
-
|
|
30
|
-
# Bottom border
|
|
31
|
-
puts @pastel.dim("╰" + "─" * width + "╯")
|
|
32
|
-
|
|
33
|
-
# Move cursor back up to input line (2 lines up)
|
|
34
|
-
print "\e[2A" # Move up 2 lines
|
|
35
|
-
print "\r" # Move to beginning of line
|
|
36
|
-
|
|
37
|
-
# Read input with TTY::Prompt
|
|
38
|
-
prompt_text = @pastel.bright_blue("#{prefix}")
|
|
39
|
-
input = read_with_tty_prompt(prompt_text)
|
|
40
|
-
|
|
41
|
-
# After input, clear the input box completely
|
|
42
|
-
# Move cursor up 2 lines to the top of the box
|
|
43
|
-
print "\e[2A"
|
|
44
|
-
print "\r"
|
|
45
|
-
|
|
46
|
-
# Clear all 3 lines of the box
|
|
47
|
-
3.times do
|
|
48
|
-
print "\e[2K" # Clear entire line
|
|
49
|
-
print "\e[1B" # Move down 1 line
|
|
50
|
-
print "\r" # Move to beginning of line
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Move cursor back up to where the box started
|
|
54
|
-
print "\e[3A"
|
|
55
|
-
print "\r"
|
|
56
|
-
|
|
57
|
-
input
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
private
|
|
61
|
-
|
|
62
|
-
def read_with_tty_prompt(prompt)
|
|
63
|
-
@tty_prompt.ask(prompt, required: false, echo: true) do |q|
|
|
64
|
-
q.modify :strip
|
|
65
|
-
end
|
|
66
|
-
rescue TTY::Reader::InputInterrupt
|
|
67
|
-
puts
|
|
68
|
-
nil
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|