openclacky 0.6.1 → 0.6.2
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 +26 -0
- data/README.md +39 -88
- data/homebrew/README.md +96 -0
- data/homebrew/openclacky.rb +24 -0
- data/lib/clacky/agent.rb +17 -70
- data/lib/clacky/cli.rb +90 -1
- data/lib/clacky/tools/file_reader.rb +135 -2
- data/lib/clacky/tools/safe_shell.rb +39 -9
- data/lib/clacky/tools/shell.rb +67 -0
- data/lib/clacky/ui2/components/input_area.rb +175 -59
- data/lib/clacky/ui2/layout_manager.rb +83 -59
- data/lib/clacky/ui2/themes/hacker_theme.rb +2 -2
- data/lib/clacky/ui2/themes/minimal_theme.rb +2 -2
- data/lib/clacky/ui2/ui_controller.rb +80 -20
- data/lib/clacky/ui2.rb +0 -1
- data/lib/clacky/utils/arguments_parser.rb +7 -2
- data/lib/clacky/utils/file_processor.rb +201 -0
- data/lib/clacky/version.rb +1 -1
- data/scripts/install.sh +249 -0
- data/scripts/uninstall.sh +146 -0
- metadata +6 -2
- data/lib/clacky/ui2/components/output_area.rb +0 -112
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "base"
|
|
4
|
+
require_relative "../utils/file_processor"
|
|
4
5
|
|
|
5
6
|
module Clacky
|
|
6
7
|
module Tools
|
|
@@ -23,6 +24,8 @@ module Clacky
|
|
|
23
24
|
},
|
|
24
25
|
required: ["path"]
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
|
|
26
29
|
|
|
27
30
|
def execute(path:, max_lines: 1000)
|
|
28
31
|
# Expand ~ to home directory
|
|
@@ -50,6 +53,12 @@ module Clacky
|
|
|
50
53
|
end
|
|
51
54
|
|
|
52
55
|
begin
|
|
56
|
+
# Check if file is binary
|
|
57
|
+
if binary_file?(expanded_path)
|
|
58
|
+
return handle_binary_file(expanded_path)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Read text file
|
|
53
62
|
lines = File.readlines(expanded_path).first(max_lines)
|
|
54
63
|
content = lines.join
|
|
55
64
|
truncated = File.readlines(expanded_path).size > max_lines
|
|
@@ -86,16 +95,140 @@ module Clacky
|
|
|
86
95
|
return "Listed #{entries} entries (#{dirs} directories, #{files} files)"
|
|
87
96
|
end
|
|
88
97
|
|
|
89
|
-
# Handle file
|
|
98
|
+
# Handle binary file
|
|
99
|
+
if result[:binary] || result['binary']
|
|
100
|
+
format_type = result[:format] || result['format'] || 'unknown'
|
|
101
|
+
size = result[:size_bytes] || result['size_bytes'] || 0
|
|
102
|
+
|
|
103
|
+
# Check if it has base64 data (LLM-compatible format)
|
|
104
|
+
if result[:base64_data] || result['base64_data']
|
|
105
|
+
size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
|
|
106
|
+
return "Binary file (#{format_type}, #{format_file_size(size)}) - sent to LLM#{size_warning}"
|
|
107
|
+
else
|
|
108
|
+
return "Binary file (#{format_type}, #{format_file_size(size)}) - cannot be read as text"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Handle text file reading
|
|
90
113
|
lines = result[:lines_read] || result['lines_read'] || 0
|
|
91
114
|
truncated = result[:truncated] || result['truncated']
|
|
92
115
|
"Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
|
|
93
116
|
end
|
|
117
|
+
|
|
118
|
+
# Format result for LLM - handles both text and binary (image/PDF) content
|
|
119
|
+
# This method is called by the agent to format tool results before sending to LLM
|
|
120
|
+
def format_result_for_llm(result)
|
|
121
|
+
# For LLM-compatible binary files with base64 data, return as content blocks
|
|
122
|
+
if result[:binary] && result[:base64_data]
|
|
123
|
+
# Create a text description
|
|
124
|
+
description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
|
|
125
|
+
|
|
126
|
+
# Add size warning for large files
|
|
127
|
+
if result[:size_bytes] > 5_000_000
|
|
128
|
+
description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# For images, return both description and image content
|
|
132
|
+
if result[:mime_type]&.start_with?("image/")
|
|
133
|
+
return {
|
|
134
|
+
type: "image",
|
|
135
|
+
path: result[:path],
|
|
136
|
+
format: result[:format],
|
|
137
|
+
size_bytes: result[:size_bytes],
|
|
138
|
+
mime_type: result[:mime_type],
|
|
139
|
+
base64_data: result[:base64_data],
|
|
140
|
+
description: description
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# For PDFs and other binary formats, just return metadata with base64
|
|
145
|
+
return {
|
|
146
|
+
type: "document",
|
|
147
|
+
path: result[:path],
|
|
148
|
+
format: result[:format],
|
|
149
|
+
size_bytes: result[:size_bytes],
|
|
150
|
+
mime_type: result[:mime_type],
|
|
151
|
+
base64_data: result[:base64_data],
|
|
152
|
+
description: description
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# For other cases, return the result as-is (agent will JSON.generate it)
|
|
157
|
+
result
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private def binary_file?(path)
|
|
161
|
+
# Use FileProcessor to detect binary files
|
|
162
|
+
File.open(path, 'rb') do |file|
|
|
163
|
+
sample = file.read(8192) || ""
|
|
164
|
+
Utils::FileProcessor.binary_file?(sample)
|
|
165
|
+
end
|
|
166
|
+
rescue StandardError
|
|
167
|
+
# If we can't read the file, assume it's not binary
|
|
168
|
+
false
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private def handle_binary_file(path)
|
|
172
|
+
# Check if it's a supported format using FileProcessor
|
|
173
|
+
if Utils::FileProcessor.supported_binary_file?(path)
|
|
174
|
+
# Use FileProcessor to convert to base64
|
|
175
|
+
begin
|
|
176
|
+
result = Utils::FileProcessor.file_to_base64(path)
|
|
177
|
+
{
|
|
178
|
+
path: path,
|
|
179
|
+
binary: true,
|
|
180
|
+
format: result[:format],
|
|
181
|
+
mime_type: result[:mime_type],
|
|
182
|
+
size_bytes: result[:size_bytes],
|
|
183
|
+
base64_data: result[:base64_data],
|
|
184
|
+
error: nil
|
|
185
|
+
}
|
|
186
|
+
rescue ArgumentError => e
|
|
187
|
+
# File too large or other error
|
|
188
|
+
file_size = File.size(path)
|
|
189
|
+
ext = File.extname(path).downcase
|
|
190
|
+
{
|
|
191
|
+
path: path,
|
|
192
|
+
binary: true,
|
|
193
|
+
format: ext.empty? ? "unknown" : ext[1..-1],
|
|
194
|
+
size_bytes: file_size,
|
|
195
|
+
content: nil,
|
|
196
|
+
error: e.message
|
|
197
|
+
}
|
|
198
|
+
end
|
|
199
|
+
else
|
|
200
|
+
# Binary file that we can't send to LLM
|
|
201
|
+
file_size = File.size(path)
|
|
202
|
+
ext = File.extname(path).downcase
|
|
203
|
+
{
|
|
204
|
+
path: path,
|
|
205
|
+
binary: true,
|
|
206
|
+
format: ext.empty? ? "unknown" : ext[1..-1],
|
|
207
|
+
size_bytes: file_size,
|
|
208
|
+
content: nil,
|
|
209
|
+
error: "Binary file detected. This format cannot be read as text. File size: #{format_file_size(file_size)}"
|
|
210
|
+
}
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private def detect_mime_type(path, data)
|
|
215
|
+
Utils::FileProcessor.detect_mime_type(path, data)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private def format_file_size(bytes)
|
|
219
|
+
if bytes < 1024
|
|
220
|
+
"#{bytes} bytes"
|
|
221
|
+
elsif bytes < 1024 * 1024
|
|
222
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
223
|
+
else
|
|
224
|
+
"#{(bytes / (1024.0 * 1024)).round(2)} MB"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
94
227
|
|
|
95
228
|
private
|
|
96
229
|
|
|
97
230
|
# List first-level directory contents (files and directories)
|
|
98
|
-
def list_directory_contents(path)
|
|
231
|
+
private def list_directory_contents(path)
|
|
99
232
|
begin
|
|
100
233
|
entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
|
|
101
234
|
|
|
@@ -190,6 +190,24 @@ module Clacky
|
|
|
190
190
|
end
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
# Override format_result_for_llm to preserve security fields
|
|
194
|
+
def format_result_for_llm(result)
|
|
195
|
+
# If security blocked, return as-is (small and important)
|
|
196
|
+
return result if result[:security_blocked]
|
|
197
|
+
|
|
198
|
+
# Call parent's format_result_for_llm to truncate output
|
|
199
|
+
compact = super(result)
|
|
200
|
+
|
|
201
|
+
# Add security enhancement fields if present (they're small and important for LLM to understand)
|
|
202
|
+
if result[:security_enhanced]
|
|
203
|
+
compact[:security_enhanced] = true
|
|
204
|
+
compact[:original_command] = result[:original_command]
|
|
205
|
+
compact[:safe_command] = result[:safe_command]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
compact
|
|
209
|
+
end
|
|
210
|
+
|
|
193
211
|
private def format_non_zero_exit(exit_code, stdout, stderr)
|
|
194
212
|
stdout_lines = stdout.lines.size
|
|
195
213
|
has_output = stdout_lines > 0
|
|
@@ -331,7 +349,12 @@ module Clacky
|
|
|
331
349
|
|
|
332
350
|
def replace_chmod_command(command)
|
|
333
351
|
# Parse chmod command to ensure it's safe
|
|
334
|
-
|
|
352
|
+
begin
|
|
353
|
+
parts = Shellwords.split(command)
|
|
354
|
+
rescue Shellwords::BadQuotedString => e
|
|
355
|
+
# If Shellwords.split fails, use simple split as fallback
|
|
356
|
+
parts = command.split(/\s+/)
|
|
357
|
+
end
|
|
335
358
|
|
|
336
359
|
# Only allow chmod +x on files in project directory
|
|
337
360
|
files = parts[2..-1] || []
|
|
@@ -340,8 +363,6 @@ module Clacky
|
|
|
340
363
|
# Allow chmod +x as it's generally safe
|
|
341
364
|
log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
|
|
342
365
|
command
|
|
343
|
-
rescue Shellwords::BadQuotedString
|
|
344
|
-
raise SecurityError, "Invalid chmod command syntax: #{command}"
|
|
345
366
|
end
|
|
346
367
|
|
|
347
368
|
def replace_curl_pipe_command(command)
|
|
@@ -370,7 +391,14 @@ module Clacky
|
|
|
370
391
|
|
|
371
392
|
def validate_and_allow(command)
|
|
372
393
|
# Check basic file operation commands
|
|
373
|
-
|
|
394
|
+
begin
|
|
395
|
+
parts = Shellwords.split(command)
|
|
396
|
+
rescue Shellwords::BadQuotedString => e
|
|
397
|
+
# If Shellwords.split fails due to quote issues, try simple split as fallback
|
|
398
|
+
# This handles cases where paths don't actually need shell escaping
|
|
399
|
+
parts = command.split(/\s+/)
|
|
400
|
+
end
|
|
401
|
+
|
|
374
402
|
cmd = parts.first
|
|
375
403
|
args = parts[1..-1]
|
|
376
404
|
|
|
@@ -384,8 +412,6 @@ module Clacky
|
|
|
384
412
|
end
|
|
385
413
|
|
|
386
414
|
command
|
|
387
|
-
rescue Shellwords::BadQuotedString
|
|
388
|
-
raise SecurityError, "Invalid command syntax: #{command}"
|
|
389
415
|
end
|
|
390
416
|
|
|
391
417
|
def validate_general_command(command)
|
|
@@ -419,11 +445,15 @@ module Clacky
|
|
|
419
445
|
end
|
|
420
446
|
|
|
421
447
|
def parse_rm_files(command)
|
|
422
|
-
|
|
448
|
+
begin
|
|
449
|
+
parts = Shellwords.split(command)
|
|
450
|
+
rescue Shellwords::BadQuotedString => e
|
|
451
|
+
# If Shellwords.split fails, use simple split as fallback
|
|
452
|
+
parts = command.split(/\s+/)
|
|
453
|
+
end
|
|
454
|
+
|
|
423
455
|
# Skip rm command itself and option parameters
|
|
424
456
|
parts.drop(1).reject { |part| part.start_with?('-') }
|
|
425
|
-
rescue Shellwords::BadQuotedString
|
|
426
|
-
raise SecurityError, "Invalid command syntax: #{command}"
|
|
427
457
|
end
|
|
428
458
|
|
|
429
459
|
def validate_file_path(path)
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -290,6 +290,73 @@ module Clacky
|
|
|
290
290
|
"[Exit #{exit_code}] #{error_msg[0..50]}"
|
|
291
291
|
end
|
|
292
292
|
end
|
|
293
|
+
|
|
294
|
+
# Format result for LLM consumption - limit output size to save tokens
|
|
295
|
+
# Maximum characters to include in LLM output
|
|
296
|
+
MAX_LLM_OUTPUT_CHARS = 2000
|
|
297
|
+
|
|
298
|
+
def format_result_for_llm(result)
|
|
299
|
+
# Return error info as-is if command failed or timed out
|
|
300
|
+
return result if result[:error] || result[:state] == 'TIMEOUT' || result[:state] == 'WAITING_INPUT'
|
|
301
|
+
|
|
302
|
+
stdout = result[:stdout] || ""
|
|
303
|
+
stderr = result[:stderr] || ""
|
|
304
|
+
exit_code = result[:exit_code] || 0
|
|
305
|
+
|
|
306
|
+
# Build compact result with truncated output
|
|
307
|
+
compact = {
|
|
308
|
+
command: result[:command],
|
|
309
|
+
exit_code: exit_code,
|
|
310
|
+
success: result[:success]
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# Add elapsed time if available
|
|
314
|
+
compact[:elapsed] = result[:elapsed] if result[:elapsed]
|
|
315
|
+
|
|
316
|
+
# Truncate stdout to save tokens
|
|
317
|
+
if stdout.empty?
|
|
318
|
+
compact[:stdout] = ""
|
|
319
|
+
else
|
|
320
|
+
stdout_lines = stdout.lines
|
|
321
|
+
stdout_line_count = stdout_lines.length
|
|
322
|
+
|
|
323
|
+
if stdout.length <= MAX_LLM_OUTPUT_CHARS
|
|
324
|
+
compact[:stdout] = stdout
|
|
325
|
+
else
|
|
326
|
+
# Take first N lines that fit within the character limit
|
|
327
|
+
accumulated_chars = 0
|
|
328
|
+
lines_to_include = []
|
|
329
|
+
|
|
330
|
+
stdout_lines.each do |line|
|
|
331
|
+
break if accumulated_chars + line.length > MAX_LLM_OUTPUT_CHARS
|
|
332
|
+
lines_to_include << line
|
|
333
|
+
accumulated_chars += line.length
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
compact[:stdout] = lines_to_include.join
|
|
337
|
+
compact[:stdout] += "\n\n... [Output truncated for LLM: showing #{lines_to_include.length} of #{stdout_line_count} lines, #{accumulated_chars} of #{stdout.length} chars] ...\n"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Truncate stderr to save tokens (usually more important than stdout, so keep more)
|
|
342
|
+
if stderr.empty?
|
|
343
|
+
compact[:stderr] = ""
|
|
344
|
+
else
|
|
345
|
+
stderr_line_count = stderr.lines.length
|
|
346
|
+
|
|
347
|
+
if stderr.length <= MAX_LLM_OUTPUT_CHARS
|
|
348
|
+
compact[:stderr] = stderr
|
|
349
|
+
else
|
|
350
|
+
compact[:stderr] = stderr[0...MAX_LLM_OUTPUT_CHARS]
|
|
351
|
+
compact[:stderr] += "\n\n... [Error output truncated for LLM: showing #{MAX_LLM_OUTPUT_CHARS} of #{stderr.length} chars, #{stderr_line_count} lines] ...\n"
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Add output_truncated flag
|
|
356
|
+
compact[:output_truncated] = result[:output_truncated] if result[:output_truncated]
|
|
357
|
+
|
|
358
|
+
compact
|
|
359
|
+
end
|
|
293
360
|
end
|
|
294
361
|
end
|
|
295
362
|
end
|
|
@@ -84,8 +84,15 @@ module Clacky
|
|
|
84
84
|
# When paused (InlineInput active), don't take up any space
|
|
85
85
|
return 0 if @paused
|
|
86
86
|
|
|
87
|
-
height =
|
|
88
|
-
|
|
87
|
+
height = 0
|
|
88
|
+
|
|
89
|
+
# Session bar - calculate actual wrapped height
|
|
90
|
+
height += calculate_sessionbar_height
|
|
91
|
+
|
|
92
|
+
# Separator after session bar
|
|
93
|
+
height += 1
|
|
94
|
+
|
|
95
|
+
# Images
|
|
89
96
|
height += @images.size
|
|
90
97
|
|
|
91
98
|
# Calculate height considering wrapped lines
|
|
@@ -101,9 +108,13 @@ module Clacky
|
|
|
101
108
|
height += wrapped_segments.size
|
|
102
109
|
end
|
|
103
110
|
|
|
104
|
-
|
|
111
|
+
# Bottom separator
|
|
112
|
+
height += 1
|
|
113
|
+
|
|
114
|
+
# Tips and user tips
|
|
105
115
|
height += 1 if @tips_message
|
|
106
116
|
height += 1 if @user_tip
|
|
117
|
+
|
|
107
118
|
height
|
|
108
119
|
end
|
|
109
120
|
|
|
@@ -202,49 +213,7 @@ module Clacky
|
|
|
202
213
|
end
|
|
203
214
|
|
|
204
215
|
# Input lines with auto-wrap support
|
|
205
|
-
|
|
206
|
-
prefix = if idx == 0
|
|
207
|
-
prompt_text = theme.format_symbol(:user) + " "
|
|
208
|
-
prompt_text
|
|
209
|
-
else
|
|
210
|
-
" " * prompt.length
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
# Calculate available width for text (excluding prefix)
|
|
214
|
-
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
215
|
-
available_width = @width - prefix_width
|
|
216
|
-
|
|
217
|
-
# Wrap line if needed
|
|
218
|
-
wrapped_segments = wrap_line(line, available_width)
|
|
219
|
-
|
|
220
|
-
wrapped_segments.each_with_index do |segment_info, wrap_idx|
|
|
221
|
-
move_cursor(current_row, 0)
|
|
222
|
-
|
|
223
|
-
segment_text = segment_info[:text]
|
|
224
|
-
segment_start = segment_info[:start]
|
|
225
|
-
segment_end = segment_info[:end]
|
|
226
|
-
|
|
227
|
-
content = if wrap_idx == 0
|
|
228
|
-
# First wrapped line includes prefix
|
|
229
|
-
if idx == @line_index
|
|
230
|
-
"#{prefix}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
|
|
231
|
-
else
|
|
232
|
-
"#{prefix}#{theme.format_text(segment_text, :user)}"
|
|
233
|
-
end
|
|
234
|
-
else
|
|
235
|
-
# Continuation lines have indent matching prefix width
|
|
236
|
-
continuation_indent = " " * prefix_width
|
|
237
|
-
if idx == @line_index
|
|
238
|
-
"#{continuation_indent}#{render_line_segment_with_cursor(line, segment_start, segment_end)}"
|
|
239
|
-
else
|
|
240
|
-
"#{continuation_indent}#{theme.format_text(segment_text, :user)}"
|
|
241
|
-
end
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
print_with_padding(content)
|
|
245
|
-
current_row += 1
|
|
246
|
-
end
|
|
247
|
-
end
|
|
216
|
+
current_row = render_input_lines(current_row)
|
|
248
217
|
|
|
249
218
|
# Bottom separator
|
|
250
219
|
render_separator(current_row)
|
|
@@ -561,6 +530,73 @@ module Clacky
|
|
|
561
530
|
|
|
562
531
|
private
|
|
563
532
|
|
|
533
|
+
# Render all input lines with auto-wrap support
|
|
534
|
+
# @param start_row [Integer] Starting row position
|
|
535
|
+
# @return [Integer] Next available row after rendering all lines
|
|
536
|
+
def render_input_lines(start_row)
|
|
537
|
+
current_row = start_row
|
|
538
|
+
|
|
539
|
+
@lines.each_with_index do |line, line_idx|
|
|
540
|
+
prefix = calculate_line_prefix(line_idx)
|
|
541
|
+
prefix_width = calculate_display_width(strip_ansi_codes(prefix))
|
|
542
|
+
available_width = @width - prefix_width
|
|
543
|
+
wrapped_segments = wrap_line(line, available_width)
|
|
544
|
+
|
|
545
|
+
wrapped_segments.each_with_index do |segment_info, wrap_idx|
|
|
546
|
+
content = render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
|
|
547
|
+
move_cursor(current_row, 0)
|
|
548
|
+
print_with_padding(content)
|
|
549
|
+
current_row += 1
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
current_row
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Calculate the prefix (prompt or indent) for a given line index
|
|
557
|
+
# @param line_idx [Integer] Index of the line
|
|
558
|
+
# @return [String] Prefix string (with formatting)
|
|
559
|
+
private def calculate_line_prefix(line_idx)
|
|
560
|
+
if line_idx == 0
|
|
561
|
+
theme.format_symbol(:user) + " "
|
|
562
|
+
else
|
|
563
|
+
" " * prompt.length
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
# Render a single segment of a line (handling cursor and wrapping)
|
|
568
|
+
# @param line [String] Full line text
|
|
569
|
+
# @param line_idx [Integer] Index of the line in @lines
|
|
570
|
+
# @param segment_info [Hash] Segment information from wrap_line
|
|
571
|
+
# @param wrap_idx [Integer] Index of this segment in wrapped segments
|
|
572
|
+
# @param prefix [String] Line prefix (prompt or indent)
|
|
573
|
+
# @param prefix_width [Integer] Display width of the prefix
|
|
574
|
+
# @return [String] Formatted content for this segment
|
|
575
|
+
private def render_line_segment(line, line_idx, segment_info, wrap_idx, prefix, prefix_width)
|
|
576
|
+
segment_text = segment_info[:text]
|
|
577
|
+
segment_start = segment_info[:start]
|
|
578
|
+
segment_end = segment_info[:end]
|
|
579
|
+
|
|
580
|
+
is_current_line = (line_idx == @line_index)
|
|
581
|
+
is_first_segment = (wrap_idx == 0)
|
|
582
|
+
|
|
583
|
+
# Determine the line prefix
|
|
584
|
+
line_prefix = if is_first_segment
|
|
585
|
+
prefix
|
|
586
|
+
else
|
|
587
|
+
" " * prefix_width # Continuation indent
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
# Render the segment content (with or without cursor)
|
|
591
|
+
segment_content = if is_current_line
|
|
592
|
+
render_line_segment_with_cursor(line, segment_start, segment_end)
|
|
593
|
+
else
|
|
594
|
+
theme.format_text(segment_text, :user)
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
"#{line_prefix}#{segment_content}"
|
|
598
|
+
end
|
|
599
|
+
|
|
564
600
|
# Wrap a line into multiple segments based on available width
|
|
565
601
|
# Considers display width of characters (multi-byte characters like Chinese)
|
|
566
602
|
# @param line [String] The line to wrap
|
|
@@ -646,12 +682,29 @@ module Clacky
|
|
|
646
682
|
visible_content = content.gsub(/\e\[[0-9;]*m/, '')
|
|
647
683
|
visible_width = calculate_display_width(visible_content)
|
|
648
684
|
|
|
649
|
-
#
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
685
|
+
# IMPORTANT: If content exceeds screen width, truncate to prevent terminal auto-wrap
|
|
686
|
+
if visible_width > @width
|
|
687
|
+
# Content too long - truncate to fit (loses ANSI colors but prevents wrapping)
|
|
688
|
+
truncate_at = 0
|
|
689
|
+
current_width = 0
|
|
690
|
+
visible_content.each_char.with_index do |char, idx|
|
|
691
|
+
char_width = char_display_width(char)
|
|
692
|
+
break if current_width + char_width + 3 > @width # Reserve 3 for "..."
|
|
693
|
+
current_width += char_width
|
|
694
|
+
truncate_at = idx + 1
|
|
695
|
+
end
|
|
696
|
+
print visible_content[0...truncate_at]
|
|
697
|
+
print "..."
|
|
698
|
+
# Pad remaining
|
|
699
|
+
remaining = @width - current_width - 3
|
|
700
|
+
print " " * remaining if remaining > 0
|
|
701
|
+
else
|
|
702
|
+
# Content fits - print normally
|
|
703
|
+
print content
|
|
704
|
+
# Pad with spaces if needed to clear old content
|
|
705
|
+
remaining = @width - visible_width
|
|
706
|
+
print " " * remaining if remaining > 0
|
|
707
|
+
end
|
|
655
708
|
end
|
|
656
709
|
|
|
657
710
|
def handle_enter
|
|
@@ -948,22 +1001,46 @@ module Clacky
|
|
|
948
1001
|
end
|
|
949
1002
|
end
|
|
950
1003
|
|
|
1004
|
+
# Render a separator line (ensures it doesn't exceed screen width)
|
|
1005
|
+
# @param row [Integer] Row position to render
|
|
951
1006
|
def render_separator(row)
|
|
952
1007
|
move_cursor(row, 0)
|
|
953
|
-
|
|
954
|
-
|
|
1008
|
+
# Ensure separator doesn't exceed screen width to prevent wrapping
|
|
1009
|
+
separator_width = [@width, 1].max
|
|
1010
|
+
content = @pastel.dim("─" * separator_width)
|
|
1011
|
+
print content
|
|
1012
|
+
# Clear any remaining space
|
|
1013
|
+
remaining = @width - separator_width
|
|
1014
|
+
print " " * remaining if remaining > 0
|
|
955
1015
|
end
|
|
956
1016
|
|
|
1017
|
+
# Render session bar with wrapping support
|
|
1018
|
+
# @param row [Integer] Starting row position
|
|
1019
|
+
# @return [Integer] Number of rows actually used
|
|
957
1020
|
def render_sessionbar(row)
|
|
958
1021
|
move_cursor(row, 0)
|
|
959
1022
|
|
|
960
1023
|
# If no sessionbar info, just render a separator
|
|
961
1024
|
unless @sessionbar_info[:working_dir]
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1025
|
+
separator_width = [@width, 1].max
|
|
1026
|
+
content = @pastel.dim("─" * separator_width)
|
|
1027
|
+
print content
|
|
1028
|
+
remaining = @width - separator_width
|
|
1029
|
+
print " " * remaining if remaining > 0
|
|
1030
|
+
return 1
|
|
965
1031
|
end
|
|
966
1032
|
|
|
1033
|
+
session_line = build_sessionbar_content
|
|
1034
|
+
|
|
1035
|
+
# IMPORTANT: Always use print_with_padding which handles truncation
|
|
1036
|
+
# to prevent terminal auto-wrap
|
|
1037
|
+
print_with_padding(session_line)
|
|
1038
|
+
1
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
# Build the session bar content string
|
|
1042
|
+
# @return [String] Formatted session bar content
|
|
1043
|
+
private def build_sessionbar_content
|
|
967
1044
|
parts = []
|
|
968
1045
|
separator = @pastel.dim(" │ ")
|
|
969
1046
|
|
|
@@ -998,8 +1075,47 @@ module Clacky
|
|
|
998
1075
|
cost_display = format("$%.1f", @sessionbar_info[:cost])
|
|
999
1076
|
parts << @pastel.dim(@pastel.white(cost_display))
|
|
1000
1077
|
|
|
1001
|
-
|
|
1002
|
-
|
|
1078
|
+
" " + parts.join(separator)
|
|
1079
|
+
end
|
|
1080
|
+
|
|
1081
|
+
# Truncate session bar content to fit within max length
|
|
1082
|
+
# @param content [String] Full session bar content with ANSI codes
|
|
1083
|
+
# @param max_length [Integer] Maximum visible length
|
|
1084
|
+
# @return [String] Truncated content
|
|
1085
|
+
private def truncate_sessionbar_content(content, max_length)
|
|
1086
|
+
# Strip ANSI codes to calculate visible length
|
|
1087
|
+
visible_content = strip_ansi_codes(content)
|
|
1088
|
+
visible_width = calculate_display_width(visible_content)
|
|
1089
|
+
|
|
1090
|
+
return content if visible_width <= max_length
|
|
1091
|
+
|
|
1092
|
+
# Truncate from the end with "..." indicator
|
|
1093
|
+
chars = visible_content.chars
|
|
1094
|
+
current_width = 0
|
|
1095
|
+
truncate_at = 0
|
|
1096
|
+
|
|
1097
|
+
chars.each_with_index do |char, idx|
|
|
1098
|
+
char_width = char_display_width(char)
|
|
1099
|
+
if current_width + char_width + 3 > max_length # Reserve 3 for "..."
|
|
1100
|
+
truncate_at = idx
|
|
1101
|
+
break
|
|
1102
|
+
end
|
|
1103
|
+
current_width += char_width
|
|
1104
|
+
truncate_at = idx + 1
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
# For simplicity with ANSI codes, just show first part + ...
|
|
1108
|
+
# This is a simplified version - proper implementation would preserve ANSI codes
|
|
1109
|
+
visible_content[0...truncate_at] + "..."
|
|
1110
|
+
end
|
|
1111
|
+
|
|
1112
|
+
# Calculate how many rows the session bar will occupy
|
|
1113
|
+
# @return [Integer] Number of rows needed
|
|
1114
|
+
private def calculate_sessionbar_height
|
|
1115
|
+
return 1 unless @sessionbar_info[:working_dir]
|
|
1116
|
+
|
|
1117
|
+
# Session bar always renders on one line (we truncate if needed)
|
|
1118
|
+
1
|
|
1003
1119
|
end
|
|
1004
1120
|
|
|
1005
1121
|
def shorten_path(path)
|