openclacky 0.6.0 → 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 +54 -0
- data/README.md +39 -88
- data/homebrew/README.md +96 -0
- data/homebrew/openclacky.rb +24 -0
- data/lib/clacky/agent.rb +139 -67
- data/lib/clacky/cli.rb +105 -6
- data/lib/clacky/tools/file_reader.rb +135 -2
- data/lib/clacky/tools/glob.rb +2 -2
- data/lib/clacky/tools/grep.rb +2 -2
- data/lib/clacky/tools/run_project.rb +5 -5
- data/lib/clacky/tools/safe_shell.rb +140 -17
- data/lib/clacky/tools/shell.rb +69 -2
- data/lib/clacky/tools/todo_manager.rb +50 -3
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_fetch.rb +2 -2
- data/lib/clacky/tools/web_search.rb +2 -2
- data/lib/clacky/ui2/components/common_component.rb +14 -5
- data/lib/clacky/ui2/components/input_area.rb +300 -89
- data/lib/clacky/ui2/components/message_component.rb +7 -3
- data/lib/clacky/ui2/components/todo_area.rb +38 -45
- data/lib/clacky/ui2/components/welcome_banner.rb +10 -0
- data/lib/clacky/ui2/layout_manager.rb +180 -50
- data/lib/clacky/ui2/markdown_renderer.rb +80 -0
- data/lib/clacky/ui2/screen_buffer.rb +26 -7
- data/lib/clacky/ui2/themes/base_theme.rb +32 -46
- data/lib/clacky/ui2/themes/hacker_theme.rb +4 -2
- data/lib/clacky/ui2/themes/minimal_theme.rb +4 -2
- data/lib/clacky/ui2/ui_controller.rb +150 -32
- data/lib/clacky/ui2/view_renderer.rb +21 -4
- 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 +21 -2
- data/lib/clacky/ui2/components/output_area.rb +0 -112
data/lib/clacky/tools/glob.rb
CHANGED
|
@@ -122,13 +122,13 @@ module Clacky
|
|
|
122
122
|
|
|
123
123
|
def format_result(result)
|
|
124
124
|
if result[:error]
|
|
125
|
-
"
|
|
125
|
+
"[Error] #{result[:error]}"
|
|
126
126
|
else
|
|
127
127
|
count = result[:returned] || 0
|
|
128
128
|
total = result[:total_matches] || 0
|
|
129
129
|
truncated = result[:truncated] ? " (truncated)" : ""
|
|
130
130
|
|
|
131
|
-
msg = "
|
|
131
|
+
msg = "[OK] Found #{count}/#{total} files#{truncated}"
|
|
132
132
|
|
|
133
133
|
# Add skipped files info if present
|
|
134
134
|
if result[:skipped_files]
|
data/lib/clacky/tools/grep.rb
CHANGED
|
@@ -212,11 +212,11 @@ module Clacky
|
|
|
212
212
|
|
|
213
213
|
def format_result(result)
|
|
214
214
|
if result[:error]
|
|
215
|
-
"
|
|
215
|
+
"[Error] #{result[:error]}"
|
|
216
216
|
else
|
|
217
217
|
matches = result[:total_matches] || 0
|
|
218
218
|
files = result[:files_with_matches] || 0
|
|
219
|
-
msg = "
|
|
219
|
+
msg = "[OK] Found #{matches} matches in #{files} files"
|
|
220
220
|
|
|
221
221
|
# Add truncation info if present
|
|
222
222
|
if result[:truncated] && result[:truncation_reason]
|
|
@@ -74,22 +74,22 @@ module Clacky
|
|
|
74
74
|
|
|
75
75
|
def format_result(result)
|
|
76
76
|
if result[:error]
|
|
77
|
-
"
|
|
77
|
+
"[Error] #{result[:error]}"
|
|
78
78
|
elsif result[:status]
|
|
79
79
|
case result[:status]
|
|
80
80
|
when 'started'
|
|
81
81
|
cmd_preview = result[:command] ? result[:command][0..50] : ''
|
|
82
82
|
output_preview = result[:output]&.lines&.first(2)&.join&.strip
|
|
83
|
-
msg = "
|
|
83
|
+
msg = "[OK] Started (PID: #{result[:pid]}, cmd: #{cmd_preview})"
|
|
84
84
|
msg += "\n #{output_preview}" if output_preview && !output_preview.empty?
|
|
85
85
|
msg
|
|
86
86
|
when 'stopped'
|
|
87
|
-
"
|
|
87
|
+
"[OK] Stopped"
|
|
88
88
|
when 'running'
|
|
89
89
|
uptime = result[:uptime] ? "#{result[:uptime].round(1)}s" : "unknown"
|
|
90
|
-
"Running
|
|
90
|
+
"[Running] #{uptime}, PID: #{result[:pid]}"
|
|
91
91
|
when 'not_running'
|
|
92
|
-
"Not
|
|
92
|
+
"[Not Running]"
|
|
93
93
|
else
|
|
94
94
|
result[:status].to_s
|
|
95
95
|
end
|
|
@@ -62,7 +62,7 @@ module Clacky
|
|
|
62
62
|
{
|
|
63
63
|
command: command,
|
|
64
64
|
stdout: "",
|
|
65
|
-
stderr: "
|
|
65
|
+
stderr: "[Security Protection] #{e.message}",
|
|
66
66
|
exit_code: 126,
|
|
67
67
|
success: false,
|
|
68
68
|
security_blocked: true
|
|
@@ -72,7 +72,10 @@ module Clacky
|
|
|
72
72
|
|
|
73
73
|
private def extract_timeout_from_command(command)
|
|
74
74
|
# Match patterns: "timeout 30 ...", "timeout 30s ...", etc.
|
|
75
|
+
# Also supports: "cd xxx && timeout 30 command", "export X=Y && timeout 30 command"
|
|
75
76
|
# Supports: timeout N command, timeout Ns command, timeout -s SIGNAL N command
|
|
77
|
+
|
|
78
|
+
# Try to match timeout at the beginning of command
|
|
76
79
|
match = command.match(/^timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
77
80
|
|
|
78
81
|
if match
|
|
@@ -81,7 +84,22 @@ module Clacky
|
|
|
81
84
|
return [actual_command, timeout_value]
|
|
82
85
|
end
|
|
83
86
|
|
|
84
|
-
#
|
|
87
|
+
# Try to match timeout after && or ;
|
|
88
|
+
# Pattern: "prefix && timeout 30 command" or "prefix; timeout 30 command"
|
|
89
|
+
match = command.match(/^(.+?)\s*(&&|;)\s*timeout\s+(?:-s\s+\w+\s+)?(\d+)s?\s+(.+)$/i)
|
|
90
|
+
|
|
91
|
+
if match
|
|
92
|
+
prefix = match[1] # e.g., "cd /tmp"
|
|
93
|
+
separator = match[2] # && or ;
|
|
94
|
+
timeout_value = match[3].to_i
|
|
95
|
+
main_command = match[4] # e.g., "bundle exec rspec"
|
|
96
|
+
|
|
97
|
+
# Reconstruct command without timeout prefix
|
|
98
|
+
actual_command = "#{prefix} #{separator} #{main_command}"
|
|
99
|
+
return [actual_command, timeout_value]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# No timeout found, return original command
|
|
85
103
|
[command, nil]
|
|
86
104
|
end
|
|
87
105
|
|
|
@@ -135,7 +153,7 @@ module Clacky
|
|
|
135
153
|
result[:safe_command] = safe_command
|
|
136
154
|
|
|
137
155
|
# Add security note to stdout
|
|
138
|
-
security_note = "
|
|
156
|
+
security_note = "[Safe] Command was automatically made safe\n"
|
|
139
157
|
result[:stdout] = security_note + (result[:stdout] || "")
|
|
140
158
|
end
|
|
141
159
|
|
|
@@ -160,18 +178,111 @@ module Clacky
|
|
|
160
178
|
stderr = result[:stderr] || result['stderr'] || ""
|
|
161
179
|
|
|
162
180
|
if result[:security_blocked]
|
|
163
|
-
"
|
|
181
|
+
"[Blocked] Security protection"
|
|
164
182
|
elsif result[:security_enhanced]
|
|
165
183
|
lines = stdout.lines.size
|
|
166
|
-
"
|
|
184
|
+
"[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
167
185
|
elsif exit_code == 0
|
|
168
186
|
lines = stdout.lines.size
|
|
169
|
-
"
|
|
187
|
+
"[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
188
|
+
else
|
|
189
|
+
format_non_zero_exit(exit_code, stdout, stderr)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
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
|
+
|
|
211
|
+
private def format_non_zero_exit(exit_code, stdout, stderr)
|
|
212
|
+
stdout_lines = stdout.lines.size
|
|
213
|
+
has_output = stdout_lines > 0
|
|
214
|
+
has_error = !stderr.empty?
|
|
215
|
+
|
|
216
|
+
if has_error
|
|
217
|
+
# Real error: show error summary
|
|
218
|
+
error_summary = extract_error_summary(stderr)
|
|
219
|
+
"[Exit #{exit_code}] #{error_summary}"
|
|
220
|
+
elsif has_output
|
|
221
|
+
# Command produced output but exited with non-zero code
|
|
222
|
+
# This is common in commands like "ls; exit 1" or grep with no matches
|
|
223
|
+
"[Exit #{exit_code}] #{stdout_lines} lines output"
|
|
170
224
|
else
|
|
171
|
-
|
|
172
|
-
"
|
|
225
|
+
# No output, no error message - just show exit code
|
|
226
|
+
"[Exit #{exit_code}] No output"
|
|
173
227
|
end
|
|
174
228
|
end
|
|
229
|
+
|
|
230
|
+
private def extract_error_summary(stderr)
|
|
231
|
+
return "No error message" if stderr.empty?
|
|
232
|
+
|
|
233
|
+
# Try to extract the most meaningful error line
|
|
234
|
+
lines = stderr.lines.map(&:strip).reject(&:empty?)
|
|
235
|
+
|
|
236
|
+
# Common error patterns with priority
|
|
237
|
+
patterns = [
|
|
238
|
+
# Ruby/Python exceptions with error type
|
|
239
|
+
{ regex: /(\w+(?:Error|Exception)):\s*(.+)$/, format: ->(m) { "#{m[1]}: #{m[2]}" } },
|
|
240
|
+
# File not found patterns
|
|
241
|
+
{ regex: /cannot load such file.*--\s*(.+)$/, format: ->(m) { "Cannot load file: #{m[1]}" } },
|
|
242
|
+
{ regex: /No such file or directory.*[@\-]\s*(.+)$/, format: ->(m) { "File not found: #{m[1]}" } },
|
|
243
|
+
# Undefined method/variable
|
|
244
|
+
{ regex: /undefined (?:local variable or )?method [`'](\w+)'/, format: ->(m) { "Undefined method: #{m[1]}" } },
|
|
245
|
+
# Syntax errors
|
|
246
|
+
{ regex: /syntax error,?\s*(.+)$/i, format: ->(m) { "Syntax error: #{m[1]}" } }
|
|
247
|
+
]
|
|
248
|
+
|
|
249
|
+
# Try each pattern on each line
|
|
250
|
+
patterns.each do |pattern|
|
|
251
|
+
lines.each do |line|
|
|
252
|
+
match = line.match(pattern[:regex])
|
|
253
|
+
if match
|
|
254
|
+
result = pattern[:format].call(match)
|
|
255
|
+
return truncate_error(clean_path(result), 80)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Fallback: find the most informative line
|
|
261
|
+
informative_line = lines.find do |line|
|
|
262
|
+
!line.start_with?('from', 'Did you mean?', '#', 'Showing full backtrace') &&
|
|
263
|
+
line.length > 10 &&
|
|
264
|
+
(line.include?(':') || line.match?(/error|failed|cannot|invalid/i))
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
if informative_line
|
|
268
|
+
return truncate_error(clean_path(informative_line), 80)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Last resort: use first meaningful line
|
|
272
|
+
first_line = lines.first || "Unknown error"
|
|
273
|
+
truncate_error(clean_path(first_line), 80)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private def clean_path(text)
|
|
277
|
+
# Remove long absolute paths, keep only filename
|
|
278
|
+
text.gsub(/\/(?:Users|home)\/[^\/]+\/[\w\/\.\-]+\/([^:\/\s]+)/, '')
|
|
279
|
+
.gsub(/\/[\w\/\.\-]{30,}\/([^:\/\s]+)/, '...')
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private def truncate_error(text, max_length)
|
|
283
|
+
return text if text.length <= max_length
|
|
284
|
+
"#{text[0...max_length-3]}..."
|
|
285
|
+
end
|
|
175
286
|
end
|
|
176
287
|
|
|
177
288
|
class CommandSafetyReplacer
|
|
@@ -238,7 +349,12 @@ module Clacky
|
|
|
238
349
|
|
|
239
350
|
def replace_chmod_command(command)
|
|
240
351
|
# Parse chmod command to ensure it's safe
|
|
241
|
-
|
|
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
|
|
242
358
|
|
|
243
359
|
# Only allow chmod +x on files in project directory
|
|
244
360
|
files = parts[2..-1] || []
|
|
@@ -247,8 +363,6 @@ module Clacky
|
|
|
247
363
|
# Allow chmod +x as it's generally safe
|
|
248
364
|
log_replacement("chmod", command, "chmod +x is allowed - file permissions will be modified")
|
|
249
365
|
command
|
|
250
|
-
rescue Shellwords::BadQuotedString
|
|
251
|
-
raise SecurityError, "Invalid chmod command syntax: #{command}"
|
|
252
366
|
end
|
|
253
367
|
|
|
254
368
|
def replace_curl_pipe_command(command)
|
|
@@ -277,7 +391,14 @@ module Clacky
|
|
|
277
391
|
|
|
278
392
|
def validate_and_allow(command)
|
|
279
393
|
# Check basic file operation commands
|
|
280
|
-
|
|
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
|
+
|
|
281
402
|
cmd = parts.first
|
|
282
403
|
args = parts[1..-1]
|
|
283
404
|
|
|
@@ -291,8 +412,6 @@ module Clacky
|
|
|
291
412
|
end
|
|
292
413
|
|
|
293
414
|
command
|
|
294
|
-
rescue Shellwords::BadQuotedString
|
|
295
|
-
raise SecurityError, "Invalid command syntax: #{command}"
|
|
296
415
|
end
|
|
297
416
|
|
|
298
417
|
def validate_general_command(command)
|
|
@@ -326,11 +445,15 @@ module Clacky
|
|
|
326
445
|
end
|
|
327
446
|
|
|
328
447
|
def parse_rm_files(command)
|
|
329
|
-
|
|
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
|
+
|
|
330
455
|
# Skip rm command itself and option parameters
|
|
331
456
|
parts.drop(1).reject { |part| part.start_with?('-') }
|
|
332
|
-
rescue Shellwords::BadQuotedString
|
|
333
|
-
raise SecurityError, "Invalid command syntax: #{command}"
|
|
334
457
|
end
|
|
335
458
|
|
|
336
459
|
def validate_file_path(path)
|
data/lib/clacky/tools/shell.rb
CHANGED
|
@@ -284,12 +284,79 @@ module Clacky
|
|
|
284
284
|
|
|
285
285
|
if exit_code == 0
|
|
286
286
|
lines = stdout.lines.size
|
|
287
|
-
"
|
|
287
|
+
"[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
|
|
288
288
|
else
|
|
289
289
|
error_msg = stderr.lines.first&.strip || "Failed"
|
|
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
|
|
@@ -29,12 +29,17 @@ module Clacky
|
|
|
29
29
|
id: {
|
|
30
30
|
type: "integer",
|
|
31
31
|
description: "The task ID (required for 'complete' and 'remove' actions)"
|
|
32
|
+
},
|
|
33
|
+
ids: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "integer" },
|
|
36
|
+
description: "Array of task IDs for batch removal (for 'remove' action). Example: [1, 3, 5]"
|
|
32
37
|
}
|
|
33
38
|
},
|
|
34
39
|
required: ["action"]
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
def execute(action:, task: nil, tasks: nil, id: nil, todos_storage: nil)
|
|
42
|
+
def execute(action:, task: nil, tasks: nil, id: nil, ids: nil, todos_storage: nil)
|
|
38
43
|
# todos_storage is injected by Agent, stores todos in memory
|
|
39
44
|
@todos = todos_storage || []
|
|
40
45
|
|
|
@@ -46,7 +51,12 @@ module Clacky
|
|
|
46
51
|
when "complete"
|
|
47
52
|
complete_todo(id)
|
|
48
53
|
when "remove"
|
|
49
|
-
|
|
54
|
+
# Support both single ID and batch IDs
|
|
55
|
+
if ids && ids.is_a?(Array)
|
|
56
|
+
remove_todos(ids)
|
|
57
|
+
else
|
|
58
|
+
remove_todo(id)
|
|
59
|
+
end
|
|
50
60
|
when "clear"
|
|
51
61
|
clear_todos
|
|
52
62
|
else
|
|
@@ -65,7 +75,12 @@ module Clacky
|
|
|
65
75
|
when 'list'
|
|
66
76
|
"TodoManager(list)"
|
|
67
77
|
when 'remove'
|
|
68
|
-
|
|
78
|
+
ids = args[:ids] || args['ids']
|
|
79
|
+
if ids && ids.is_a?(Array) && !ids.empty?
|
|
80
|
+
"TodoManager(remove #{ids.size} tasks: #{ids.join(', ')})"
|
|
81
|
+
else
|
|
82
|
+
"TodoManager(remove ##{args[:id] || args['id']})"
|
|
83
|
+
end
|
|
69
84
|
when 'clear'
|
|
70
85
|
"TodoManager(clear all)"
|
|
71
86
|
else
|
|
@@ -223,6 +238,38 @@ module Clacky
|
|
|
223
238
|
cleared_count: count
|
|
224
239
|
}
|
|
225
240
|
end
|
|
241
|
+
|
|
242
|
+
def remove_todos(ids)
|
|
243
|
+
return { error: "Task IDs array is required" } if ids.nil? || ids.empty?
|
|
244
|
+
|
|
245
|
+
todos = load_todos
|
|
246
|
+
removed_todos = []
|
|
247
|
+
not_found_ids = []
|
|
248
|
+
|
|
249
|
+
ids.each do |id|
|
|
250
|
+
todo = todos.find { |t| t[:id] == id }
|
|
251
|
+
if todo
|
|
252
|
+
removed_todos << todo
|
|
253
|
+
else
|
|
254
|
+
not_found_ids << id
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Remove all found todos
|
|
259
|
+
todos.reject! { |t| ids.include?(t[:id]) }
|
|
260
|
+
save_todos(todos)
|
|
261
|
+
|
|
262
|
+
result = {
|
|
263
|
+
message: "#{removed_todos.size} task(s) removed",
|
|
264
|
+
removed: removed_todos,
|
|
265
|
+
remaining: todos.size
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
# Add warning about not found IDs
|
|
269
|
+
result[:not_found] = not_found_ids unless not_found_ids.empty?
|
|
270
|
+
|
|
271
|
+
result
|
|
272
|
+
end
|
|
226
273
|
end
|
|
227
274
|
end
|
|
228
275
|
end
|
|
@@ -149,11 +149,11 @@ module Clacky
|
|
|
149
149
|
|
|
150
150
|
def format_result(result)
|
|
151
151
|
if result[:error]
|
|
152
|
-
"
|
|
152
|
+
"[Error] #{result[:error]}"
|
|
153
153
|
else
|
|
154
154
|
title = result[:title] || 'Untitled'
|
|
155
155
|
display_title = title.length > 40 ? "#{title[0..37]}..." : title
|
|
156
|
-
"
|
|
156
|
+
"[OK] Fetched: #{display_title}"
|
|
157
157
|
end
|
|
158
158
|
end
|
|
159
159
|
end
|
|
@@ -139,10 +139,10 @@ module Clacky
|
|
|
139
139
|
|
|
140
140
|
def format_result(result)
|
|
141
141
|
if result[:error]
|
|
142
|
-
"
|
|
142
|
+
"[Error] #{result[:error]}"
|
|
143
143
|
else
|
|
144
144
|
count = result[:count] || 0
|
|
145
|
-
"
|
|
145
|
+
"[OK] Found #{count} results"
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
end
|
|
@@ -15,7 +15,7 @@ module Clacky
|
|
|
15
15
|
"#{symbol} #{text}"
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
# Render progress indicator
|
|
18
|
+
# Render progress indicator (stopped state, gray)
|
|
19
19
|
# @param message [String] Progress message
|
|
20
20
|
# @return [String] Progress indicator
|
|
21
21
|
def render_progress(message)
|
|
@@ -24,6 +24,15 @@ module Clacky
|
|
|
24
24
|
"#{symbol} #{text}"
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Render working indicator (active state, yellow)
|
|
28
|
+
# @param message [String] Progress message
|
|
29
|
+
# @return [String] Working indicator
|
|
30
|
+
def render_working(message)
|
|
31
|
+
symbol = format_symbol(:working)
|
|
32
|
+
text = format_text(message, :working)
|
|
33
|
+
"#{symbol} #{text}"
|
|
34
|
+
end
|
|
35
|
+
|
|
27
36
|
# Render success message
|
|
28
37
|
# @param message [String] Success message
|
|
29
38
|
# @return [String] Success message
|
|
@@ -65,22 +74,22 @@ module Clacky
|
|
|
65
74
|
lines << @pastel.dim("─" * 60)
|
|
66
75
|
lines << render_success("Task Complete")
|
|
67
76
|
lines << ""
|
|
68
|
-
|
|
77
|
+
|
|
69
78
|
# Display each stat on a separate line
|
|
70
79
|
lines << " Iterations: #{iterations}"
|
|
71
80
|
lines << " Cost: $#{cost.round(4)}"
|
|
72
81
|
lines << " Duration: #{duration.round(1)}s" if duration
|
|
73
|
-
|
|
82
|
+
|
|
74
83
|
# Display cache information if available
|
|
75
84
|
if cache_tokens && cache_tokens > 0
|
|
76
85
|
lines << " Cache Tokens: #{cache_tokens} tokens"
|
|
77
86
|
end
|
|
78
|
-
|
|
87
|
+
|
|
79
88
|
if cache_requests && cache_requests > 0
|
|
80
89
|
hit_rate = cache_hits > 0 ? ((cache_hits.to_f / cache_requests) * 100).round(1) : 0
|
|
81
90
|
lines << " Cache Requests: #{cache_requests} (#{cache_hits} hits, #{hit_rate}% hit rate)"
|
|
82
91
|
end
|
|
83
|
-
|
|
92
|
+
|
|
84
93
|
lines.join("\n")
|
|
85
94
|
end
|
|
86
95
|
end
|