openclacky 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/clacky/agent.rb +34 -16
- data/lib/clacky/cli.rb +48 -131
- data/lib/clacky/client.rb +53 -7
- data/lib/clacky/model_pricing.rb +280 -0
- data/lib/clacky/tools/grep.rb +30 -7
- data/lib/clacky/tools/safe_shell.rb +9 -4
- data/lib/clacky/tools/shell.rb +60 -22
- data/lib/clacky/ui/banner.rb +22 -11
- data/lib/clacky/ui/enhanced_prompt.rb +61 -164
- data/lib/clacky/ui/statusbar.rb +0 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +1 -1
- metadata +2 -2
- data/lib/clacky/conversation.rb +0 -41
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
# Module for handling AI model pricing
|
|
5
|
+
# Supports different pricing tiers and prompt caching
|
|
6
|
+
module ModelPricing
|
|
7
|
+
# Pricing per 1M tokens (MTok) in USD
|
|
8
|
+
# All pricing is based on official API documentation
|
|
9
|
+
PRICING_TABLE = {
|
|
10
|
+
# Claude 4.5 models - tiered pricing based on prompt length
|
|
11
|
+
"claude-opus-4.5" => {
|
|
12
|
+
input: {
|
|
13
|
+
default: 5.00, # $5/MTok for prompts ≤ 200K tokens
|
|
14
|
+
over_200k: 5.00 # same for all tiers
|
|
15
|
+
},
|
|
16
|
+
output: {
|
|
17
|
+
default: 25.00, # $25/MTok for prompts ≤ 200K tokens
|
|
18
|
+
over_200k: 25.00 # same for all tiers
|
|
19
|
+
},
|
|
20
|
+
cache: {
|
|
21
|
+
write: 6.25, # $6.25/MTok cache write
|
|
22
|
+
read: 0.50 # $0.50/MTok cache read
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
"claude-sonnet-4.5" => {
|
|
27
|
+
input: {
|
|
28
|
+
default: 3.00, # $3/MTok for prompts ≤ 200K tokens
|
|
29
|
+
over_200k: 6.00 # $6/MTok for prompts > 200K tokens
|
|
30
|
+
},
|
|
31
|
+
output: {
|
|
32
|
+
default: 15.00, # $15/MTok for prompts ≤ 200K tokens
|
|
33
|
+
over_200k: 22.50 # $22.50/MTok for prompts > 200K tokens
|
|
34
|
+
},
|
|
35
|
+
cache: {
|
|
36
|
+
write_default: 3.75, # $3.75/MTok cache write (≤ 200K)
|
|
37
|
+
write_over_200k: 7.50, # $7.50/MTok cache write (> 200K)
|
|
38
|
+
read_default: 0.30, # $0.30/MTok cache read (≤ 200K)
|
|
39
|
+
read_over_200k: 0.60 # $0.60/MTok cache read (> 200K)
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
"claude-haiku-4.5" => {
|
|
44
|
+
input: {
|
|
45
|
+
default: 1.00, # $1/MTok
|
|
46
|
+
over_200k: 1.00 # same for all tiers
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
default: 5.00, # $5/MTok
|
|
50
|
+
over_200k: 5.00 # same for all tiers
|
|
51
|
+
},
|
|
52
|
+
cache: {
|
|
53
|
+
write: 1.25, # $1.25/MTok cache write
|
|
54
|
+
read: 0.10 # $0.10/MTok cache read
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
# Claude 3.5 models (for backwards compatibility)
|
|
59
|
+
"claude-3-5-sonnet-20241022" => {
|
|
60
|
+
input: {
|
|
61
|
+
default: 3.00,
|
|
62
|
+
over_200k: 6.00
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
default: 15.00,
|
|
66
|
+
over_200k: 22.50
|
|
67
|
+
},
|
|
68
|
+
cache: {
|
|
69
|
+
write_default: 3.75,
|
|
70
|
+
write_over_200k: 7.50,
|
|
71
|
+
read_default: 0.30,
|
|
72
|
+
read_over_200k: 0.60
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
"claude-3-5-sonnet-20240620" => {
|
|
77
|
+
input: {
|
|
78
|
+
default: 3.00,
|
|
79
|
+
over_200k: 6.00
|
|
80
|
+
},
|
|
81
|
+
output: {
|
|
82
|
+
default: 15.00,
|
|
83
|
+
over_200k: 22.50
|
|
84
|
+
},
|
|
85
|
+
cache: {
|
|
86
|
+
write_default: 3.75,
|
|
87
|
+
write_over_200k: 7.50,
|
|
88
|
+
read_default: 0.30,
|
|
89
|
+
read_over_200k: 0.60
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
"claude-3-5-haiku-20241022" => {
|
|
94
|
+
input: {
|
|
95
|
+
default: 1.00,
|
|
96
|
+
over_200k: 1.00
|
|
97
|
+
},
|
|
98
|
+
output: {
|
|
99
|
+
default: 5.00,
|
|
100
|
+
over_200k: 5.00
|
|
101
|
+
},
|
|
102
|
+
cache: {
|
|
103
|
+
write: 1.25,
|
|
104
|
+
read: 0.10
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
# Default fallback pricing (conservative estimates)
|
|
109
|
+
"default" => {
|
|
110
|
+
input: {
|
|
111
|
+
default: 0.50,
|
|
112
|
+
over_200k: 0.50
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
default: 1.50,
|
|
116
|
+
over_200k: 1.50
|
|
117
|
+
},
|
|
118
|
+
cache: {
|
|
119
|
+
write: 0.625,
|
|
120
|
+
read: 0.05
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}.freeze
|
|
124
|
+
|
|
125
|
+
# Threshold for tiered pricing (200K tokens)
|
|
126
|
+
TIERED_PRICING_THRESHOLD = 200_000
|
|
127
|
+
|
|
128
|
+
class << self
|
|
129
|
+
# Calculate cost for the given model and usage
|
|
130
|
+
#
|
|
131
|
+
# @param model [String] Model identifier
|
|
132
|
+
# @param usage [Hash] Usage statistics containing:
|
|
133
|
+
# - prompt_tokens: number of input tokens
|
|
134
|
+
# - completion_tokens: number of output tokens
|
|
135
|
+
# - cache_creation_input_tokens: tokens written to cache (optional)
|
|
136
|
+
# - cache_read_input_tokens: tokens read from cache (optional)
|
|
137
|
+
# @return [Hash] Hash containing:
|
|
138
|
+
# - cost: Cost in USD (Float)
|
|
139
|
+
# - source: Cost source (:price or :default) (Symbol)
|
|
140
|
+
def calculate_cost(model:, usage:)
|
|
141
|
+
pricing_result = get_pricing_with_source(model)
|
|
142
|
+
pricing = pricing_result[:pricing]
|
|
143
|
+
source = pricing_result[:source]
|
|
144
|
+
|
|
145
|
+
prompt_tokens = usage[:prompt_tokens] || 0
|
|
146
|
+
completion_tokens = usage[:completion_tokens] || 0
|
|
147
|
+
cache_write_tokens = usage[:cache_creation_input_tokens] || 0
|
|
148
|
+
cache_read_tokens = usage[:cache_read_input_tokens] || 0
|
|
149
|
+
|
|
150
|
+
# Determine if we're in the over_200k tier
|
|
151
|
+
total_input_tokens = prompt_tokens + cache_write_tokens + cache_read_tokens
|
|
152
|
+
over_threshold = total_input_tokens > TIERED_PRICING_THRESHOLD
|
|
153
|
+
|
|
154
|
+
# Calculate regular input cost (non-cached tokens)
|
|
155
|
+
regular_input_tokens = prompt_tokens - cache_write_tokens - cache_read_tokens
|
|
156
|
+
input_rate = over_threshold ? pricing[:input][:over_200k] : pricing[:input][:default]
|
|
157
|
+
input_cost = (regular_input_tokens / 1_000_000.0) * input_rate
|
|
158
|
+
|
|
159
|
+
# Calculate output cost
|
|
160
|
+
output_rate = over_threshold ? pricing[:output][:over_200k] : pricing[:output][:default]
|
|
161
|
+
output_cost = (completion_tokens / 1_000_000.0) * output_rate
|
|
162
|
+
|
|
163
|
+
# Calculate cache costs
|
|
164
|
+
cache_cost = calculate_cache_cost(
|
|
165
|
+
pricing: pricing,
|
|
166
|
+
cache_write_tokens: cache_write_tokens,
|
|
167
|
+
cache_read_tokens: cache_read_tokens,
|
|
168
|
+
over_threshold: over_threshold
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
{
|
|
172
|
+
cost: input_cost + output_cost + cache_cost,
|
|
173
|
+
source: source
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Get pricing for a specific model
|
|
178
|
+
# Falls back to default pricing if model not found
|
|
179
|
+
#
|
|
180
|
+
# @param model [String] Model identifier
|
|
181
|
+
# @return [Hash] Pricing structure for the model
|
|
182
|
+
def get_pricing(model)
|
|
183
|
+
get_pricing_with_source(model)[:pricing]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get pricing with source information
|
|
187
|
+
#
|
|
188
|
+
# @param model [String] Model identifier
|
|
189
|
+
# @return [Hash] Hash containing:
|
|
190
|
+
# - pricing: Pricing structure for the model
|
|
191
|
+
# - source: :price (matched model) or :default (fallback)
|
|
192
|
+
def get_pricing_with_source(model)
|
|
193
|
+
# Normalize model name (remove version suffixes, handle variations)
|
|
194
|
+
normalized_model = normalize_model_name(model)
|
|
195
|
+
|
|
196
|
+
if normalized_model == "default"
|
|
197
|
+
# Using default fallback pricing
|
|
198
|
+
{
|
|
199
|
+
pricing: PRICING_TABLE["default"],
|
|
200
|
+
source: :default
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
# Found specific pricing for this model
|
|
204
|
+
{
|
|
205
|
+
pricing: PRICING_TABLE[normalized_model],
|
|
206
|
+
source: :price
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
# Normalize model name to match pricing table keys
|
|
214
|
+
def normalize_model_name(model)
|
|
215
|
+
return "default" if model.nil? || model.empty?
|
|
216
|
+
|
|
217
|
+
model = model.downcase.strip
|
|
218
|
+
|
|
219
|
+
# Direct match
|
|
220
|
+
return model if PRICING_TABLE.key?(model)
|
|
221
|
+
|
|
222
|
+
# Check for Claude model variations
|
|
223
|
+
# Support both dot and dash separators (e.g., "4.5" or "4-5")
|
|
224
|
+
case model
|
|
225
|
+
when /claude.*opus.*4[.-]?5/i
|
|
226
|
+
"claude-opus-4.5"
|
|
227
|
+
when /claude.*sonnet.*4[.-]?5/i
|
|
228
|
+
"claude-sonnet-4.5"
|
|
229
|
+
when /claude.*haiku.*4[.-]?5/i
|
|
230
|
+
"claude-haiku-4.5"
|
|
231
|
+
when /claude-3-5-sonnet-20241022/i
|
|
232
|
+
"claude-3-5-sonnet-20241022"
|
|
233
|
+
when /claude-3-5-sonnet-20240620/i
|
|
234
|
+
"claude-3-5-sonnet-20240620"
|
|
235
|
+
when /claude-3-5-haiku-20241022/i
|
|
236
|
+
"claude-3-5-haiku-20241022"
|
|
237
|
+
else
|
|
238
|
+
"default"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Calculate cache-related costs
|
|
243
|
+
def calculate_cache_cost(pricing:, cache_write_tokens:, cache_read_tokens:, over_threshold:)
|
|
244
|
+
cache_cost = 0.0
|
|
245
|
+
|
|
246
|
+
# Cache write cost
|
|
247
|
+
if cache_write_tokens > 0
|
|
248
|
+
write_rate = if pricing[:cache].key?(:write)
|
|
249
|
+
# Simple pricing (Opus 4.5, Haiku 4.5)
|
|
250
|
+
pricing[:cache][:write]
|
|
251
|
+
elsif over_threshold
|
|
252
|
+
# Tiered pricing (Sonnet 4.5)
|
|
253
|
+
pricing[:cache][:write_over_200k]
|
|
254
|
+
else
|
|
255
|
+
pricing[:cache][:write_default]
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
cache_cost += (cache_write_tokens / 1_000_000.0) * write_rate
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Cache read cost
|
|
262
|
+
if cache_read_tokens > 0
|
|
263
|
+
read_rate = if pricing[:cache].key?(:read)
|
|
264
|
+
# Simple pricing (Opus 4.5, Haiku 4.5)
|
|
265
|
+
pricing[:cache][:read]
|
|
266
|
+
elsif over_threshold
|
|
267
|
+
# Tiered pricing (Sonnet 4.5)
|
|
268
|
+
pricing[:cache][:read_over_200k]
|
|
269
|
+
else
|
|
270
|
+
pricing[:cache][:read_default]
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
cache_cost += (cache_read_tokens / 1_000_000.0) * read_rate
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
cache_cost
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -136,7 +136,7 @@ module Clacky
|
|
|
136
136
|
|
|
137
137
|
# Initialize gitignore parser
|
|
138
138
|
gitignore_path = find_gitignore(expanded_path)
|
|
139
|
-
gitignore = gitignore_path ? GitignoreParser.new(gitignore_path) : nil
|
|
139
|
+
gitignore = gitignore_path ? Clacky::GitignoreParser.new(gitignore_path) : nil
|
|
140
140
|
|
|
141
141
|
results = []
|
|
142
142
|
total_matches = 0
|
|
@@ -259,18 +259,30 @@ module Clacky
|
|
|
259
259
|
private
|
|
260
260
|
|
|
261
261
|
# Find .gitignore file in the search path or parent directories
|
|
262
|
+
# Only searches within the search path and up to the current working directory
|
|
262
263
|
def find_gitignore(path)
|
|
263
264
|
search_path = File.directory?(path) ? path : File.dirname(path)
|
|
264
265
|
|
|
265
266
|
# Look for .gitignore in current and parent directories
|
|
266
267
|
current = File.expand_path(search_path)
|
|
268
|
+
cwd = File.expand_path(Dir.pwd)
|
|
267
269
|
root = File.expand_path('/')
|
|
268
270
|
|
|
271
|
+
# Limit search: only go up to current working directory
|
|
272
|
+
# This prevents finding .gitignore files from unrelated parent directories
|
|
273
|
+
# when searching in temporary directories (like /tmp in tests)
|
|
274
|
+
search_limit = if current.start_with?(cwd)
|
|
275
|
+
cwd
|
|
276
|
+
else
|
|
277
|
+
current
|
|
278
|
+
end
|
|
279
|
+
|
|
269
280
|
loop do
|
|
270
281
|
gitignore = File.join(current, '.gitignore')
|
|
271
282
|
return gitignore if File.exist?(gitignore)
|
|
272
283
|
|
|
273
|
-
|
|
284
|
+
# Stop if we've reached the search limit or root
|
|
285
|
+
break if current == search_limit || current == root
|
|
274
286
|
current = File.dirname(current)
|
|
275
287
|
end
|
|
276
288
|
|
|
@@ -279,24 +291,35 @@ module Clacky
|
|
|
279
291
|
|
|
280
292
|
# Check if file should be ignored based on .gitignore or default patterns
|
|
281
293
|
def should_ignore_file?(file, base_path, gitignore)
|
|
294
|
+
# Always calculate path relative to base_path for consistency
|
|
295
|
+
# Expand both paths to handle symlinks and relative paths correctly
|
|
296
|
+
expanded_file = File.expand_path(file)
|
|
297
|
+
expanded_base = File.expand_path(base_path)
|
|
298
|
+
|
|
299
|
+
# For files, use the directory as base
|
|
300
|
+
expanded_base = File.dirname(expanded_base) if File.file?(expanded_base)
|
|
301
|
+
|
|
282
302
|
# Calculate relative path
|
|
283
|
-
if
|
|
284
|
-
relative_path =
|
|
303
|
+
if expanded_file.start_with?(expanded_base)
|
|
304
|
+
relative_path = expanded_file[(expanded_base.length + 1)..-1] || File.basename(expanded_file)
|
|
285
305
|
else
|
|
286
|
-
|
|
306
|
+
# File is outside base path - use just the filename
|
|
307
|
+
relative_path = File.basename(expanded_file)
|
|
287
308
|
end
|
|
309
|
+
|
|
310
|
+
# Clean up relative path
|
|
288
311
|
relative_path = relative_path.sub(/^\.\//, '') if relative_path
|
|
289
|
-
relative_path ||= file
|
|
290
312
|
|
|
291
313
|
if gitignore
|
|
292
314
|
# Use .gitignore rules
|
|
293
315
|
gitignore.ignored?(relative_path)
|
|
294
316
|
else
|
|
295
|
-
# Use default ignore patterns
|
|
317
|
+
# Use default ignore patterns - only match against relative path components
|
|
296
318
|
DEFAULT_IGNORED_PATTERNS.any? do |pattern|
|
|
297
319
|
if pattern.include?('*')
|
|
298
320
|
File.fnmatch(pattern, relative_path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
299
321
|
else
|
|
322
|
+
# Match pattern as a path component (not substring of absolute path)
|
|
300
323
|
relative_path.start_with?("#{pattern}/") ||
|
|
301
324
|
relative_path.include?("/#{pattern}/") ||
|
|
302
325
|
relative_path == pattern ||
|
|
@@ -26,12 +26,17 @@ module Clacky
|
|
|
26
26
|
hard_timeout: {
|
|
27
27
|
type: "integer",
|
|
28
28
|
description: "Hard timeout in seconds (force kill)"
|
|
29
|
+
},
|
|
30
|
+
max_output_lines: {
|
|
31
|
+
type: "integer",
|
|
32
|
+
description: "Maximum number of output lines to return (default: 1000)",
|
|
33
|
+
default: 1000
|
|
29
34
|
}
|
|
30
35
|
},
|
|
31
36
|
required: ["command"]
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
def execute(command:, soft_timeout: nil, hard_timeout: nil)
|
|
39
|
+
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
|
|
35
40
|
# Get project root directory
|
|
36
41
|
project_root = Dir.pwd
|
|
37
42
|
|
|
@@ -41,7 +46,7 @@ module Clacky
|
|
|
41
46
|
safe_command = safety_replacer.make_command_safe(command)
|
|
42
47
|
|
|
43
48
|
# 2. Call parent class execution method
|
|
44
|
-
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout)
|
|
49
|
+
result = super(command: safe_command, soft_timeout: soft_timeout, hard_timeout: hard_timeout, max_output_lines: max_output_lines)
|
|
45
50
|
|
|
46
51
|
# 3. Enhance result information
|
|
47
52
|
enhance_result(result, command, safe_command)
|
|
@@ -112,8 +117,8 @@ module Clacky
|
|
|
112
117
|
return "safe_shell(<no command>)" if cmd.empty?
|
|
113
118
|
|
|
114
119
|
# Truncate long commands intelligently
|
|
115
|
-
if cmd.length >
|
|
116
|
-
"safe_shell(\"#{cmd[0..
|
|
120
|
+
if cmd.length > 150
|
|
121
|
+
"safe_shell(\"#{cmd[0..147]}...\")"
|
|
117
122
|
else
|
|
118
123
|
"safe_shell(\"#{cmd}\")"
|
|
119
124
|
end
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -22,6 +22,11 @@ module Clacky
|
|
|
22
22
|
hard_timeout: {
|
|
23
23
|
type: "integer",
|
|
24
24
|
description: "Hard timeout in seconds (force kill)"
|
|
25
|
+
},
|
|
26
|
+
max_output_lines: {
|
|
27
|
+
type: "integer",
|
|
28
|
+
description: "Maximum number of output lines to return (default: 1000)",
|
|
29
|
+
default: 1000
|
|
25
30
|
}
|
|
26
31
|
},
|
|
27
32
|
required: ["command"]
|
|
@@ -51,7 +56,7 @@ module Clacky
|
|
|
51
56
|
'go build'
|
|
52
57
|
].freeze
|
|
53
58
|
|
|
54
|
-
def execute(command:, soft_timeout: nil, hard_timeout: nil)
|
|
59
|
+
def execute(command:, soft_timeout: nil, hard_timeout: nil, max_output_lines: 1000)
|
|
55
60
|
require "open3"
|
|
56
61
|
require "stringio"
|
|
57
62
|
|
|
@@ -82,7 +87,8 @@ module Clacky
|
|
|
82
87
|
stderr_buffer.string,
|
|
83
88
|
elapsed,
|
|
84
89
|
:hard_timeout,
|
|
85
|
-
hard_timeout
|
|
90
|
+
hard_timeout,
|
|
91
|
+
max_output_lines
|
|
86
92
|
)
|
|
87
93
|
end
|
|
88
94
|
|
|
@@ -97,7 +103,8 @@ module Clacky
|
|
|
97
103
|
command,
|
|
98
104
|
stdout_buffer.string,
|
|
99
105
|
stderr_buffer.string,
|
|
100
|
-
interaction
|
|
106
|
+
interaction,
|
|
107
|
+
max_output_lines
|
|
101
108
|
)
|
|
102
109
|
end
|
|
103
110
|
|
|
@@ -114,7 +121,8 @@ module Clacky
|
|
|
114
121
|
command,
|
|
115
122
|
stdout_buffer.string,
|
|
116
123
|
stderr_buffer.string,
|
|
117
|
-
elapsed
|
|
124
|
+
elapsed,
|
|
125
|
+
max_output_lines
|
|
118
126
|
)
|
|
119
127
|
end
|
|
120
128
|
end
|
|
@@ -151,22 +159,30 @@ module Clacky
|
|
|
151
159
|
rescue StandardError
|
|
152
160
|
end
|
|
153
161
|
|
|
162
|
+
stdout_output = stdout_buffer.string
|
|
163
|
+
stderr_output = stderr_buffer.string
|
|
164
|
+
|
|
154
165
|
{
|
|
155
166
|
command: command,
|
|
156
|
-
stdout:
|
|
157
|
-
stderr:
|
|
167
|
+
stdout: truncate_output(stdout_output, max_output_lines),
|
|
168
|
+
stderr: truncate_output(stderr_output, max_output_lines),
|
|
158
169
|
exit_code: wait_thr.value.exitstatus,
|
|
159
170
|
success: wait_thr.value.success?,
|
|
160
|
-
elapsed: Time.now - start_time
|
|
171
|
+
elapsed: Time.now - start_time,
|
|
172
|
+
output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
|
|
161
173
|
}
|
|
162
174
|
end
|
|
163
175
|
rescue StandardError => e
|
|
176
|
+
stdout_output = stdout_buffer.string
|
|
177
|
+
stderr_output = "Error executing command: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
|
178
|
+
|
|
164
179
|
{
|
|
165
180
|
command: command,
|
|
166
|
-
stdout:
|
|
167
|
-
stderr:
|
|
181
|
+
stdout: truncate_output(stdout_output, max_output_lines),
|
|
182
|
+
stderr: truncate_output(stderr_output, max_output_lines),
|
|
168
183
|
exit_code: -1,
|
|
169
|
-
success: false
|
|
184
|
+
success: false,
|
|
185
|
+
output_truncated: output_truncated?(stdout_output, stderr_output, max_output_lines)
|
|
170
186
|
}
|
|
171
187
|
end
|
|
172
188
|
end
|
|
@@ -206,16 +222,17 @@ module Clacky
|
|
|
206
222
|
nil
|
|
207
223
|
end
|
|
208
224
|
|
|
209
|
-
def format_waiting_input_result(command, stdout, stderr, interaction)
|
|
225
|
+
def format_waiting_input_result(command, stdout, stderr, interaction, max_output_lines)
|
|
210
226
|
{
|
|
211
227
|
command: command,
|
|
212
|
-
stdout: stdout,
|
|
213
|
-
stderr: stderr,
|
|
228
|
+
stdout: truncate_output(stdout, max_output_lines),
|
|
229
|
+
stderr: truncate_output(stderr, max_output_lines),
|
|
214
230
|
exit_code: -2,
|
|
215
231
|
success: false,
|
|
216
232
|
state: 'WAITING_INPUT',
|
|
217
233
|
interaction_type: interaction[:type],
|
|
218
|
-
message: format_waiting_message(stdout, interaction)
|
|
234
|
+
message: format_waiting_message(truncate_output(stdout, max_output_lines), interaction),
|
|
235
|
+
output_truncated: output_truncated?(stdout, stderr, max_output_lines)
|
|
219
236
|
}
|
|
220
237
|
end
|
|
221
238
|
|
|
@@ -238,16 +255,17 @@ module Clacky
|
|
|
238
255
|
MSG
|
|
239
256
|
end
|
|
240
257
|
|
|
241
|
-
def format_stuck_result(command, stdout, stderr, elapsed)
|
|
258
|
+
def format_stuck_result(command, stdout, stderr, elapsed, max_output_lines)
|
|
242
259
|
{
|
|
243
260
|
command: command,
|
|
244
|
-
stdout: stdout,
|
|
245
|
-
stderr: stderr,
|
|
261
|
+
stdout: truncate_output(stdout, max_output_lines),
|
|
262
|
+
stderr: truncate_output(stderr, max_output_lines),
|
|
246
263
|
exit_code: -3,
|
|
247
264
|
success: false,
|
|
248
265
|
state: 'STUCK',
|
|
249
266
|
elapsed: elapsed,
|
|
250
|
-
message: format_stuck_message(stdout, elapsed)
|
|
267
|
+
message: format_stuck_message(truncate_output(stdout, max_output_lines), elapsed),
|
|
268
|
+
output_truncated: output_truncated?(stdout, stderr, max_output_lines)
|
|
251
269
|
}
|
|
252
270
|
end
|
|
253
271
|
|
|
@@ -267,18 +285,38 @@ module Clacky
|
|
|
267
285
|
MSG
|
|
268
286
|
end
|
|
269
287
|
|
|
270
|
-
def format_timeout_result(command, stdout, stderr, elapsed, type, timeout)
|
|
288
|
+
def format_timeout_result(command, stdout, stderr, elapsed, type, timeout, max_output_lines)
|
|
271
289
|
{
|
|
272
290
|
command: command,
|
|
273
|
-
stdout: stdout,
|
|
274
|
-
stderr: stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr,
|
|
291
|
+
stdout: truncate_output(stdout, max_output_lines),
|
|
292
|
+
stderr: truncate_output(stderr.empty? ? "Command timed out after #{elapsed.round(1)} seconds (#{type}=#{timeout}s)" : stderr, max_output_lines),
|
|
275
293
|
exit_code: -1,
|
|
276
294
|
success: false,
|
|
277
295
|
state: 'TIMEOUT',
|
|
278
|
-
timeout_type: type
|
|
296
|
+
timeout_type: type,
|
|
297
|
+
output_truncated: output_truncated?(stdout, stderr, max_output_lines)
|
|
279
298
|
}
|
|
280
299
|
end
|
|
281
300
|
|
|
301
|
+
# Truncate output to max_lines, adding a truncation notice if needed
|
|
302
|
+
def truncate_output(output, max_lines)
|
|
303
|
+
return output if output.nil? || output.empty?
|
|
304
|
+
|
|
305
|
+
lines = output.lines
|
|
306
|
+
return output if lines.length <= max_lines
|
|
307
|
+
|
|
308
|
+
truncated_lines = lines.first(max_lines)
|
|
309
|
+
truncation_notice = "\n\n... [Output truncated: showing #{max_lines} of #{lines.length} lines] ...\n"
|
|
310
|
+
truncated_lines.join + truncation_notice
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Check if output was truncated
|
|
314
|
+
def output_truncated?(stdout, stderr, max_lines)
|
|
315
|
+
stdout_lines = stdout&.lines&.length || 0
|
|
316
|
+
stderr_lines = stderr&.lines&.length || 0
|
|
317
|
+
stdout_lines > max_lines || stderr_lines > max_lines
|
|
318
|
+
end
|
|
319
|
+
|
|
282
320
|
def format_call(args)
|
|
283
321
|
cmd = args[:command] || args['command'] || ''
|
|
284
322
|
cmd_parts = cmd.split
|
data/lib/clacky/ui/banner.rb
CHANGED
|
@@ -9,10 +9,10 @@ module Clacky
|
|
|
9
9
|
LOGO = <<~'LOGO'
|
|
10
10
|
██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗██╗ █████╗ ██████╗██╗ ██╗██╗ ██╗
|
|
11
11
|
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██║ ██╔══██╗██╔════╝██║ ██╔╝╚██╗ ██╔╝
|
|
12
|
-
██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
|
|
13
|
-
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
|
|
14
|
-
╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
|
|
15
|
-
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
|
|
12
|
+
██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ███████║██║ █████╔╝ ╚████╔╝
|
|
13
|
+
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██╔══██║██║ ██╔═██╗ ╚██╔╝
|
|
14
|
+
╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗███████╗██║ ██║╚██████╗██║ ██╗ ██║
|
|
15
|
+
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝
|
|
16
16
|
LOGO
|
|
17
17
|
|
|
18
18
|
TAGLINE = "[>] AI Coding Assistant & Technical Co-founder"
|
|
@@ -72,25 +72,36 @@ module Clacky
|
|
|
72
72
|
end
|
|
73
73
|
|
|
74
74
|
# Display task completion summary
|
|
75
|
-
def display_task_complete(iterations:, cost:, total_tasks:, total_cost:, cache_stats: {})
|
|
75
|
+
def display_task_complete(iterations:, cost:, total_tasks:, total_cost:, cost_source:, cache_stats: {})
|
|
76
76
|
puts
|
|
77
77
|
puts separator("-")
|
|
78
78
|
puts @pastel.bright_green("[✓] TASK COMPLETED")
|
|
79
79
|
puts info_line("Iterations", iterations)
|
|
80
|
-
|
|
80
|
+
|
|
81
|
+
# Add cost source indicator
|
|
82
|
+
cost_suffix = case cost_source
|
|
83
|
+
when :api
|
|
84
|
+
" (by API)"
|
|
85
|
+
when :price
|
|
86
|
+
" (by PRICE)"
|
|
87
|
+
when :default
|
|
88
|
+
" (by DEFAULT)"
|
|
89
|
+
else
|
|
90
|
+
" (estimated)"
|
|
91
|
+
end
|
|
92
|
+
puts info_line("Cost", "$#{cost}#{cost_suffix}")
|
|
81
93
|
puts info_line("Session Total", "#{total_tasks} tasks, $#{total_cost}")
|
|
82
|
-
|
|
94
|
+
|
|
83
95
|
# Display cache statistics if available
|
|
84
96
|
if cache_stats[:total_requests] && cache_stats[:total_requests] > 0
|
|
85
97
|
puts
|
|
86
98
|
puts @pastel.cyan(" [Prompt Caching]")
|
|
87
|
-
puts info_line(" Cache Writes", "#{cache_stats[:cache_creation_input_tokens]} tokens")
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
puts info_line(" Cache Reads/Writes", "#{cache_stats[:cache_read_input_tokens]}/#{cache_stats[:cache_creation_input_tokens]} tokens")
|
|
100
|
+
|
|
90
101
|
hit_rate = (cache_stats[:cache_hit_requests].to_f / cache_stats[:total_requests] * 100).round(1)
|
|
91
102
|
puts info_line(" Cache Hit Rate", "#{hit_rate}% (#{cache_stats[:cache_hit_requests]}/#{cache_stats[:total_requests]} requests)")
|
|
92
103
|
end
|
|
93
|
-
|
|
104
|
+
|
|
94
105
|
puts separator("-")
|
|
95
106
|
puts
|
|
96
107
|
end
|