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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52ab39c40c69d821b45d01ed1d14086a50446309421891e9cd6724a9b32864cf
4
- data.tar.gz: 0d61ef02a23f8a04157f8b7b2308a4f0c68fd37bba1407894b9841cd707c6873
3
+ metadata.gz: f86dc057b8fd69db07c40a5633ffe1350cf0ddb8509b6e26705464d9e127daa4
4
+ data.tar.gz: 58d3e6b89e129d6c7ef7dbc57ff57e95ee8d6acd96186491daa8670b6031c3b2
5
5
  SHA512:
6
- metadata.gz: d654bd29a093bf23f1e0c162867251e08a28ec722deaae7a9d234f2aed913329d991df29391778612c458c51b6775881ee0867cb2d959c0f7072b14e662d0c38
7
- data.tar.gz: 8056dff60d1c845751021e2c852e58071360c8d6a6b81cec72be238e647521ae36a211d72af88602239f8abcab5a15b7a345b8efd98e68b62a0a6dfde8a63b23
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 rubocop]
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/prompt"
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
- current_message = prompt.read_input(prefix: "You:")
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::Prompt.new
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
@@ -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
- max_matches: {
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(pattern:, path: ".", file_pattern: "**/*", case_insensitive: false, context_lines: 0, max_matches: 50)
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
- unless File.exist?(path)
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?(path)
66
- [path]
152
+ files = if File.file?(expanded_path)
153
+ [expanded_path]
67
154
  else
68
- Dir.glob(File.join(path, file_pattern))
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
- break if results.length >= max_matches
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
- matches = search_file(file, regex, context_lines)
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: files.length,
217
+ files_searched: files_searched,
91
218
  files_with_matches: results.length,
92
- truncated: results.length >= max_matches,
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
- def search_file(file, regex, context_lines)
126
- matches = []
127
- lines = File.readlines(file, chomp: true)
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
- lines.each_with_index do |line, index|
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
- # Get context
133
- start_line = [0, index - context_lines].max
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
- (start_line..end_line).each do |i|
138
- context << {
139
- line_number: i + 1,
140
- content: lines[i],
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: line,
148
- context: context_lines > 0 ? context : nil
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.5.3"
4
+ VERSION = "0.5.4"
5
5
  end
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/prompt"
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.3
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
@@ -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