openclacky 0.6.0 → 0.6.1

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: cf44c03777d71d011e78e17171352ea824da23fa3cc5bcb09c8ba985134ea28e
4
- data.tar.gz: bafeeb81f93b770c2929c9a71a11fd8ed8f344251d284d10031023c41390f73b
3
+ metadata.gz: 9c052d03a1c67251ddb1310bd82b4d1bbee0129716f4e0f2ec664dfdf3b52ced
4
+ data.tar.gz: 1d04af0f03f0e03e5ef41a312b2074a8b53a57d2fc9b38f95e73fe3baed3ffd4
5
5
  SHA512:
6
- metadata.gz: 8d4be2efc7721ec808722faa943d3fe99e4a074bebdaf710be79c8b55cfc1ef5f6757ddf33f5c37dc63ebe020bbd135f98b19d3be287121756d020b86fdd3e8f
7
- data.tar.gz: cb451f53e1d1c589760c1bfccfd530faa9bfafa22d1b3e6aaca1a03813dd5bc524fb3c12037c3f0fd84cf18c8a76d71d6e6fc02421bd7e314cde8b0d783156b5
6
+ metadata.gz: 6a78384406082c30a41e3546b145230276532567664e2fcf2d6de579a7765966d9ce837629c24b1e35e27f5b9a60b7e1337d6f95981665f74cc99cdee52773ef
7
+ data.tar.gz: 6da58385006861fcb511c56febf1a0c81d5f30825354e3610201ea2b4c53a29f0d65fd41cf89447c9347c396de4dadb77f7139e4a69d19d0f5debcc986e5536f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.1] - 2026-01-29
11
+
12
+ ### Added
13
+ - User tips for better guidance and feedback
14
+ - Batch TODO operations for improved task management
15
+ - Markdown output support for better formatted responses
16
+ - Text style customization options
17
+
18
+ ### Improved
19
+ - Tool execution with slow progress indicators for long-running operations
20
+ - Progress UI refinements for better visual feedback
21
+ - Session restore now shows recent messages for context
22
+ - TODO area UI enhancements with auto-hide when all tasks completed
23
+ - Work status bar styling improvements
24
+ - Text wrapping when moving input to output area
25
+ - Safe shell output improvements for better readability
26
+ - Task info display optimization (only show essential information)
27
+ - TODO list cleanup and organization
28
+
29
+ ### Fixed
30
+ - Double paste bug causing duplicate input
31
+ - Double error message display issue
32
+ - TODO clear functionality
33
+ - RSpec test hanging issues
34
+
35
+ ### Removed
36
+ - Tool emoji from output for cleaner display
37
+
10
38
  ## [0.6.0] - 2026-01-28
11
39
 
12
40
  ### Added
data/lib/clacky/agent.rb CHANGED
@@ -129,6 +129,33 @@ module Clacky
129
129
  end
130
130
  end
131
131
 
132
+ # Get recent user messages from conversation history
133
+ # @param limit [Integer] Number of recent user messages to retrieve (default: 5)
134
+ # @return [Array<String>] Array of recent user message contents
135
+ def get_recent_user_messages(limit: 5)
136
+ # Filter messages to only include real user messages (exclude system-injected ones)
137
+ user_messages = @messages.select do |m|
138
+ m[:role] == "user" && !m[:system_injected]
139
+ end
140
+
141
+ # Extract text content from the last N user messages
142
+ user_messages.last(limit).map do |msg|
143
+ extract_text_from_content(msg[:content])
144
+ end
145
+ end
146
+
147
+ private def extract_text_from_content(content)
148
+ if content.is_a?(String)
149
+ content
150
+ elsif content.is_a?(Array)
151
+ # Extract text from content array (may contain text and images)
152
+ text_parts = content.select { |c| c.is_a?(Hash) && c[:type] == "text" }
153
+ text_parts.map { |c| c[:text] }.join("\n")
154
+ else
155
+ content.to_s
156
+ end
157
+ end
158
+
132
159
  def add_hook(event, &block)
133
160
  @hooks.add(event, &block)
134
161
  end
@@ -137,6 +164,16 @@ module Clacky
137
164
  @start_time = Time.now
138
165
  @task_cost_source = :estimated # Reset for new task
139
166
  @previous_total_tokens = 0 # Reset token tracking for new task
167
+ @task_start_iterations = @iterations # Track starting iterations for this task
168
+ @task_start_cost = @total_cost # Track starting cost for this task
169
+
170
+ # Track cache stats for current task
171
+ @task_cache_stats = {
172
+ cache_creation_input_tokens: 0,
173
+ cache_read_input_tokens: 0,
174
+ total_requests: 0,
175
+ cache_hit_requests: 0
176
+ }
140
177
 
141
178
  # Add system prompt as the first message if this is the first run
142
179
  if @messages.empty?
@@ -193,16 +230,17 @@ module Clacky
193
230
  if action_result[:denied]
194
231
  # If user provided feedback, treat it as a user question/instruction
195
232
  if action_result[:feedback] && !action_result[:feedback].empty?
196
- # Add user feedback as a new user message
233
+ # Add user feedback as a new user message with system_injected marker
197
234
  @messages << {
198
235
  role: "user",
199
- content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions."
236
+ content: "STOP. The user has a question/feedback for you: #{action_result[:feedback]}\n\nPlease respond to the user's question/feedback before continuing with any actions.",
237
+ system_injected: true # Mark as system-injected message for filtering
200
238
  }
201
239
  # Continue loop to let agent respond to feedback
202
240
  next
203
241
  else
204
242
  # User just said "no" without feedback - stop and wait
205
- @ui&.show_assistant_message("Tool execution was denied. Please provide further instructions.")
243
+ @ui&.show_assistant_message("Tool execution was denied. Please give more instructions...")
206
244
  break
207
245
  end
208
246
  end
@@ -221,8 +259,8 @@ module Clacky
221
259
  # Let CLI handle the interrupt message
222
260
  raise
223
261
  rescue StandardError => e
262
+ # Build error result for session data, but let CLI handle error display
224
263
  result = build_result(:error, error: e.message)
225
- @ui&.show_error("Error: #{e.message}")
226
264
  raise
227
265
  end
228
266
  end
@@ -405,8 +443,8 @@ module Clacky
405
443
  end
406
444
  end
407
445
 
408
- # Stop progress thread (but keep progress line visible)
409
- @ui&.stop_progress_thread
446
+ # Clear progress indicator (change to gray and show final time)
447
+ @ui&.clear_progress
410
448
 
411
449
  track_cost(response[:usage], raw_api_usage: response[:raw_api_usage])
412
450
 
@@ -494,7 +532,13 @@ module Clacky
494
532
 
495
533
  confirmation = confirm_tool_use?(call)
496
534
  unless confirmation[:approved]
497
- @ui&.show_warning("Tool #{call[:name]} denied")
535
+ # Show denial warning with user feedback if provided
536
+ denial_message = "Tool #{call[:name]} denied"
537
+ if confirmation[:feedback] && !confirmation[:feedback].empty?
538
+ denial_message += ": #{confirmation[:feedback]}"
539
+ end
540
+ @ui&.show_warning(denial_message)
541
+
498
542
  denied = true
499
543
  user_feedback = confirmation[:feedback]
500
544
  feedback = user_feedback if user_feedback
@@ -526,8 +570,17 @@ module Clacky
526
570
  args[:todos_storage] = @todos
527
571
  end
528
572
 
573
+ # Show progress for potentially slow tools (no prefix newline)
574
+ if potentially_slow_tool?(call[:name], args)
575
+ progress_message = build_tool_progress_message(call[:name], args)
576
+ @ui&.show_progress(progress_message, prefix_newline: false)
577
+ end
578
+
529
579
  result = tool.execute(**args)
530
580
 
581
+ # Clear progress if shown
582
+ @ui&.clear_progress if potentially_slow_tool?(call[:name], args)
583
+
531
584
  # Hook: after_tool_use
532
585
  @hooks.trigger(:after_tool_use, call, result)
533
586
 
@@ -606,6 +659,53 @@ module Clacky
606
659
  false
607
660
  end
608
661
 
662
+ # Check if a tool is potentially slow and should show progress
663
+ private def potentially_slow_tool?(tool_name, args)
664
+ case tool_name.to_s.downcase
665
+ when 'shell', 'safe_shell'
666
+ # Check if the command is a slow command
667
+ command = args[:command] || args['command']
668
+ return false unless command
669
+
670
+ # List of slow command patterns
671
+ slow_patterns = [
672
+ /bundle\s+(install|exec\s+rspec|exec\s+rake)/,
673
+ /npm\s+(install|run\s+test|run\s+build)/,
674
+ /yarn\s+(install|test|build)/,
675
+ /pnpm\s+install/,
676
+ /cargo\s+(build|test)/,
677
+ /go\s+(build|test)/,
678
+ /make\s+(test|build)/,
679
+ /pytest/,
680
+ /jest/
681
+ ]
682
+
683
+ slow_patterns.any? { |pattern| command.match?(pattern) }
684
+ when 'web_fetch', 'web_search'
685
+ true # Network operations can be slow
686
+ else
687
+ false # Most file operations are fast
688
+ end
689
+ end
690
+
691
+ # Build progress message for tool execution
692
+ private def build_tool_progress_message(tool_name, args)
693
+ case tool_name.to_s.downcase
694
+ when 'shell', 'safe_shell'
695
+ command = args[:command] || args['command']
696
+ # Extract the main command for display
697
+ cmd_parts = command.to_s.split
698
+ main_cmd = cmd_parts.first(2).join(' ')
699
+ "Running #{main_cmd}"
700
+ when 'web_fetch'
701
+ "Fetching web page"
702
+ when 'web_search'
703
+ "Searching web"
704
+ else
705
+ "Executing #{tool_name}"
706
+ end
707
+ end
708
+
609
709
  def track_cost(usage, raw_api_usage: nil)
610
710
  # Priority 1: Use API-provided cost if available (OpenRouter, LiteLLM, etc.)
611
711
  iteration_cost = nil
@@ -637,7 +737,7 @@ module Clacky
637
737
  # Display token usage statistics for this iteration
638
738
  display_iteration_tokens(usage, iteration_cost)
639
739
 
640
- # Track cache usage statistics
740
+ # Track cache usage statistics (global)
641
741
  @cache_stats[:total_requests] += 1
642
742
 
643
743
  if usage[:cache_creation_input_tokens]
@@ -655,6 +755,20 @@ module Clacky
655
755
  @cache_stats[:raw_api_usage_samples] << raw_api_usage
656
756
  @cache_stats[:raw_api_usage_samples] = @cache_stats[:raw_api_usage_samples].last(3)
657
757
  end
758
+
759
+ # Track cache usage for current task
760
+ if @task_cache_stats
761
+ @task_cache_stats[:total_requests] += 1
762
+
763
+ if usage[:cache_creation_input_tokens]
764
+ @task_cache_stats[:cache_creation_input_tokens] += usage[:cache_creation_input_tokens]
765
+ end
766
+
767
+ if usage[:cache_read_input_tokens]
768
+ @task_cache_stats[:cache_read_input_tokens] += usage[:cache_read_input_tokens]
769
+ @task_cache_stats[:cache_hit_requests] += 1
770
+ end
771
+ end
658
772
  end
659
773
 
660
774
  # Display token usage for current iteration
@@ -867,9 +981,14 @@ module Clacky
867
981
  when true
868
982
  { approved: true, feedback: nil }
869
983
  when false, nil
984
+ # User denied - add visual marker based on tool type
985
+ tool_name_capitalized = call[:name].capitalize
986
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
870
987
  { approved: false, feedback: nil }
871
988
  else
872
- # String feedback
989
+ # String feedback - also add visual marker
990
+ tool_name_capitalized = call[:name].capitalize
991
+ @ui&.show_info(" ↳ #{tool_name_capitalized} cancelled", prefix_newline: false)
873
992
  { approved: false, feedback: result.to_s }
874
993
  end
875
994
  else
@@ -1079,14 +1198,20 @@ module Clacky
1079
1198
  end
1080
1199
 
1081
1200
  def build_result(status, error: nil)
1201
+ # Calculate iterations for current task only
1202
+ task_iterations = @iterations - (@task_start_iterations || 0)
1203
+
1204
+ # Calculate cost for current task only
1205
+ task_cost = @total_cost - (@task_start_cost || 0)
1206
+
1082
1207
  {
1083
1208
  status: status,
1084
1209
  session_id: @session_id,
1085
- iterations: @iterations,
1210
+ iterations: task_iterations, # Show only current task iterations
1086
1211
  duration_seconds: Time.now - @start_time,
1087
- total_cost_usd: @total_cost.round(4),
1212
+ total_cost_usd: task_cost.round(4), # Show only current task cost
1088
1213
  cost_source: @task_cost_source, # Add cost source for this task
1089
- cache_stats: @cache_stats,
1214
+ cache_stats: @task_cache_stats || @cache_stats, # Use task cache stats if available
1090
1215
  messages: @messages,
1091
1216
  error: error
1092
1217
  }
data/lib/clacky/cli.rb CHANGED
@@ -82,11 +82,14 @@ module Clacky
82
82
  # Handle session loading/continuation
83
83
  session_manager = Clacky::SessionManager.new
84
84
  agent = nil
85
+ is_session_load = false
85
86
 
86
87
  if options[:continue]
87
88
  agent = load_latest_session(client, agent_config, session_manager, working_dir)
89
+ is_session_load = !agent.nil?
88
90
  elsif options[:attach]
89
91
  agent = load_session_by_number(client, agent_config, session_manager, working_dir, options[:attach])
92
+ is_session_load = !agent.nil?
90
93
  end
91
94
 
92
95
  # Create new agent if no session loaded
@@ -98,7 +101,7 @@ module Clacky
98
101
  Dir.chdir(working_dir) if should_chdir
99
102
 
100
103
  begin
101
- run_agent_with_ui2(agent, working_dir, agent_config, message, session_manager, client)
104
+ run_agent_with_ui2(agent, working_dir, agent_config, message, session_manager, client, is_session_load: is_session_load)
102
105
  rescue StandardError => e
103
106
  # Save session on error
104
107
  if session_manager
@@ -231,7 +234,7 @@ module Clacky
231
234
  return nil
232
235
  end
233
236
 
234
- say "Loading latest session: #{session_data[:session_id][0..7]}", :green
237
+ # Don't print message here - will be shown by UI after banner
235
238
  Clacky::Agent.from_session(client, agent_config, session_data)
236
239
  end
237
240
 
@@ -276,7 +279,7 @@ module Clacky
276
279
  end
277
280
  end
278
281
 
279
- say "Loading session: #{session_data[:session_id][0..7]}", :green
282
+ # Don't print message here - will be shown by UI after banner
280
283
  Clacky::Agent.from_session(client, agent_config, session_data)
281
284
  end
282
285
 
@@ -296,7 +299,7 @@ module Clacky
296
299
  end
297
300
 
298
301
  # Run agent with UI2 split-screen interface
299
- def run_agent_with_ui2(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil)
302
+ def run_agent_with_ui2(agent, working_dir, agent_config, initial_message = nil, session_manager = nil, client = nil, is_session_load: false)
300
303
  # Create UI2 controller with configuration
301
304
  ui_controller = UI2::UIController.new(
302
305
  working_dir: working_dir,
@@ -359,6 +362,8 @@ module Clacky
359
362
  ui_controller.show_info("Session cleared. Starting fresh.")
360
363
  # Update session bar with reset values
361
364
  ui_controller.update_sessionbar(tasks: agent.total_tasks, cost: agent.total_cost)
365
+ # Clear todo area display
366
+ ui_controller.update_todos([])
362
367
  next
363
368
  when "/exit", "/quit"
364
369
  ui_controller.stop
@@ -400,7 +405,12 @@ module Clacky
400
405
  end
401
406
 
402
407
  # Initialize UI screen first
403
- ui_controller.initialize_and_show_banner
408
+ if is_session_load
409
+ recent_user_messages = agent.get_recent_user_messages(limit: 5)
410
+ ui_controller.initialize_and_show_banner(recent_user_messages: recent_user_messages)
411
+ else
412
+ ui_controller.initialize_and_show_banner
413
+ end
404
414
 
405
415
  # If there's an initial message, process it
406
416
  if initial_message && !initial_message.strip.empty?
@@ -122,13 +122,13 @@ module Clacky
122
122
 
123
123
  def format_result(result)
124
124
  if result[:error]
125
- " #{result[:error]}"
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 = " Found #{count}/#{total} files#{truncated}"
131
+ msg = "[OK] Found #{count}/#{total} files#{truncated}"
132
132
 
133
133
  # Add skipped files info if present
134
134
  if result[:skipped_files]
@@ -212,11 +212,11 @@ module Clacky
212
212
 
213
213
  def format_result(result)
214
214
  if result[:error]
215
- " #{result[:error]}"
215
+ "[Error] #{result[:error]}"
216
216
  else
217
217
  matches = result[:total_matches] || 0
218
218
  files = result[:files_with_matches] || 0
219
- msg = " Found #{matches} matches in #{files} files"
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
- " #{result[:error]}"
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 = " Started (PID: #{result[:pid]}, cmd: #{cmd_preview})"
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
- " Stopped"
87
+ "[OK] Stopped"
88
88
  when 'running'
89
89
  uptime = result[:uptime] ? "#{result[:uptime].round(1)}s" : "unknown"
90
- "Running (#{uptime}, PID: #{result[:pid]})"
90
+ "[Running] #{uptime}, PID: #{result[:pid]}"
91
91
  when 'not_running'
92
- "Not running"
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: "🔒 Security Protection: #{e.message}",
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
- # No timeout prefix found, return original command
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 = "🔒 Command was automatically made safe\n"
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,93 @@ module Clacky
160
178
  stderr = result[:stderr] || result['stderr'] || ""
161
179
 
162
180
  if result[:security_blocked]
163
- "🔒 Blocked for security"
181
+ "[Blocked] Security protection"
164
182
  elsif result[:security_enhanced]
165
183
  lines = stdout.lines.size
166
- "🔒✓ Safe execution#{lines > 0 ? " (#{lines} lines)" : ''}"
184
+ "[Safe] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
167
185
  elsif exit_code == 0
168
186
  lines = stdout.lines.size
169
- " Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
187
+ "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
170
188
  else
171
- error_msg = stderr.lines.first&.strip || "Failed"
172
- "✗ Exit #{exit_code}: #{error_msg[0..50]}"
189
+ format_non_zero_exit(exit_code, stdout, stderr)
173
190
  end
174
191
  end
192
+
193
+ private def format_non_zero_exit(exit_code, stdout, stderr)
194
+ stdout_lines = stdout.lines.size
195
+ has_output = stdout_lines > 0
196
+ has_error = !stderr.empty?
197
+
198
+ if has_error
199
+ # Real error: show error summary
200
+ error_summary = extract_error_summary(stderr)
201
+ "[Exit #{exit_code}] #{error_summary}"
202
+ elsif has_output
203
+ # Command produced output but exited with non-zero code
204
+ # This is common in commands like "ls; exit 1" or grep with no matches
205
+ "[Exit #{exit_code}] #{stdout_lines} lines output"
206
+ else
207
+ # No output, no error message - just show exit code
208
+ "[Exit #{exit_code}] No output"
209
+ end
210
+ end
211
+
212
+ private def extract_error_summary(stderr)
213
+ return "No error message" if stderr.empty?
214
+
215
+ # Try to extract the most meaningful error line
216
+ lines = stderr.lines.map(&:strip).reject(&:empty?)
217
+
218
+ # Common error patterns with priority
219
+ patterns = [
220
+ # Ruby/Python exceptions with error type
221
+ { regex: /(\w+(?:Error|Exception)):\s*(.+)$/, format: ->(m) { "#{m[1]}: #{m[2]}" } },
222
+ # File not found patterns
223
+ { regex: /cannot load such file.*--\s*(.+)$/, format: ->(m) { "Cannot load file: #{m[1]}" } },
224
+ { regex: /No such file or directory.*[@\-]\s*(.+)$/, format: ->(m) { "File not found: #{m[1]}" } },
225
+ # Undefined method/variable
226
+ { regex: /undefined (?:local variable or )?method [`'](\w+)'/, format: ->(m) { "Undefined method: #{m[1]}" } },
227
+ # Syntax errors
228
+ { regex: /syntax error,?\s*(.+)$/i, format: ->(m) { "Syntax error: #{m[1]}" } }
229
+ ]
230
+
231
+ # Try each pattern on each line
232
+ patterns.each do |pattern|
233
+ lines.each do |line|
234
+ match = line.match(pattern[:regex])
235
+ if match
236
+ result = pattern[:format].call(match)
237
+ return truncate_error(clean_path(result), 80)
238
+ end
239
+ end
240
+ end
241
+
242
+ # Fallback: find the most informative line
243
+ informative_line = lines.find do |line|
244
+ !line.start_with?('from', 'Did you mean?', '#', 'Showing full backtrace') &&
245
+ line.length > 10 &&
246
+ (line.include?(':') || line.match?(/error|failed|cannot|invalid/i))
247
+ end
248
+
249
+ if informative_line
250
+ return truncate_error(clean_path(informative_line), 80)
251
+ end
252
+
253
+ # Last resort: use first meaningful line
254
+ first_line = lines.first || "Unknown error"
255
+ truncate_error(clean_path(first_line), 80)
256
+ end
257
+
258
+ private def clean_path(text)
259
+ # Remove long absolute paths, keep only filename
260
+ text.gsub(/\/(?:Users|home)\/[^\/]+\/[\w\/\.\-]+\/([^:\/\s]+)/, '')
261
+ .gsub(/\/[\w\/\.\-]{30,}\/([^:\/\s]+)/, '...')
262
+ end
263
+
264
+ private def truncate_error(text, max_length)
265
+ return text if text.length <= max_length
266
+ "#{text[0...max_length-3]}..."
267
+ end
175
268
  end
176
269
 
177
270
  class CommandSafetyReplacer
@@ -284,10 +284,10 @@ module Clacky
284
284
 
285
285
  if exit_code == 0
286
286
  lines = stdout.lines.size
287
- " Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
287
+ "[OK] Completed#{lines > 0 ? " (#{lines} lines)" : ''}"
288
288
  else
289
289
  error_msg = stderr.lines.first&.strip || "Failed"
290
- "Exit #{exit_code}: #{error_msg[0..50]}"
290
+ "[Exit #{exit_code}] #{error_msg[0..50]}"
291
291
  end
292
292
  end
293
293
  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
- remove_todo(id)
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
- "TodoManager(remove ##{args[:id] || args['id']})"
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